diff --git a/src/components/chat/chat-header/ChatHeader.spec.tsx b/src/components/chat/chat-header/ChatHeader.spec.tsx new file mode 100644 index 00000000..bb3d8e5f --- /dev/null +++ b/src/components/chat/chat-header/ChatHeader.spec.tsx @@ -0,0 +1,374 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import ChatHeader from './ChatHeader'; +import { pipelinesService } from '@/services/pipelines/pipelinesService'; +import chatService from '@/services/chat/chatService'; +import { toast } from 'sonner'; + +vi.mock('@/services/pipelines/pipelinesService', () => ({ + pipelinesService: { + getPipelines: vi.fn(), + getPipelinesByConversation: vi.fn(), + addItemToPipeline: vi.fn(), + moveItem: vi.fn(), + removeItemFromPipeline: vi.fn(), + }, +})); + +vi.mock('@/services/chat/chatService', () => ({ + default: { + getConversation: vi.fn(), + }, +})); + +const mockUpdateConversation = vi.fn(); +vi.mock('@/contexts/chat/ChatContext', () => ({ + useChatContext: () => ({ + conversations: { updateConversation: mockUpdateConversation }, + }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock('@/hooks/useLanguage', () => ({ + useLanguage: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/utils/chat/conversationStatus', () => ({ + getStatusLabel: (s: string) => s, + isPendingStatus: () => false, +})); + +vi.mock('@/utils/channelUtils', () => ({ + isPhoneBearingChannel: () => false, +})); + +vi.mock('@/utils/contact/formatContactPhone', () => ({ + formatContactPhone: (p: string) => p, +})); + +vi.mock('@/components/chat/contact/ContactAvatar', () => ({ + default: () =>
, +})); + +const makePipeline = ( + id: string, + stages: { id: string; name: string }[], + items: { id: string; item_id: string; stage_id: string }[] = [], +) => ({ + id, + name: `Pipeline ${id}`, + pipeline_type: 'custom' as const, + visibility: 'public' as const, + is_active: true, + stages: stages.map(s => ({ ...s, color: '#000', position: 0, created_at: '', updated_at: '' })), + items, + created_at: '', + updated_at: '', +}); + +const makeConversation = (id = '42') => + ({ + id, + status: 'open' as const, + inbox: { id: '1', name: 'WhatsApp', channel_type: 'Channel::Whatsapp' }, + contact: { id: '1', name: 'Test Contact' }, + custom_attributes: {}, + }) as never; + +const defaultProps = { + conversation: makeConversation(), + onBackClick: vi.fn(), + onCloseConversation: vi.fn(), + onContactSidebarOpen: vi.fn(), + onMarkAsRead: vi.fn(), + onMarkAsUnread: vi.fn(), + onMarkAsOpen: vi.fn(), + onMarkAsResolved: vi.fn(), + onPostpone: vi.fn(), + onMarkAsSnoozed: vi.fn(), + onSetPriority: vi.fn(), + onPinConversation: vi.fn(), + onUnpinConversation: vi.fn(), + onArchiveConversation: vi.fn(), + onUnarchiveConversation: vi.fn(), + onAssignAgent: vi.fn(), + onAssignTeam: vi.fn(), + onAssignTag: vi.fn(), + onDeleteConversation: vi.fn(), + unreadCount: 0, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(chatService.getConversation).mockResolvedValue({ data: makeConversation() } as never); +}); + +const openPipelineAndSelectStage = async ( + user: ReturnType, + pipelineName: string, + stageName: string, +) => { + // Open main dropdown + const menuTrigger = document.querySelector('[data-slot="dropdown-menu-trigger"]')!; + await user.click(menuTrigger); + + // Find and focus the pipeline.addTo sub-trigger, then open it with ArrowRight + const addToTrigger = await screen.findByText('pipeline.addTo'); + const addToSubTrigger = (addToTrigger.closest('[data-slot="dropdown-menu-sub-trigger"]') ?? addToTrigger) as HTMLElement; + addToSubTrigger.focus(); + await user.keyboard('{ArrowRight}'); + + // Find and focus the specific pipeline sub-trigger, then open with ArrowRight + await waitFor(() => screen.getByText(pipelineName), { timeout: 2000 }); + const pipelineEl = screen.getByText(pipelineName); + const pipelineSubTrigger = (pipelineEl.closest('[data-slot="dropdown-menu-sub-trigger"]') ?? pipelineEl) as HTMLElement; + pipelineSubTrigger.focus(); + await user.keyboard('{ArrowRight}'); + + // Find and click the stage + await waitFor(() => screen.getByText(stageName), { timeout: 2000 }); + await user.click(screen.getByText(stageName)); +}; + +describe('ChatHeader pipeline', () => { + it('loads pipelines on mount and calls getPipelinesByConversation when menu opens', async () => { + const pipeline = makePipeline('p1', [{ id: 'stage-1', name: 'Lead' }]); + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never); + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([]); + + render(); + + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalledWith({ is_active: true })); + + const user = userEvent.setup(); + const menuTrigger = document.querySelector('[data-slot="dropdown-menu-trigger"]')!; + await user.click(menuTrigger); + + await waitFor(() => + expect(pipelinesService.getPipelinesByConversation).toHaveBeenCalledWith('42'), + ); + }); + + it('adds conversation to pipeline using conversation.id not item.id (H1)', async () => { + const pipeline = makePipeline('p1', [{ id: 'stage-1', name: 'Lead' }]); + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never); + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([]); + vi.mocked(pipelinesService.addItemToPipeline).mockResolvedValue({} as never); + + render(); + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); + + const user = userEvent.setup(); + await openPipelineAndSelectStage(user, 'Pipeline p1', 'Lead'); + + await waitFor(() => { + expect(pipelinesService.addItemToPipeline).toHaveBeenCalledWith('p1', { + item_id: '42', + type: 'conversation', + pipeline_stage_id: 'stage-1', + }); + }); + }); + + it('calls moveItem with item.id when moving within same pipeline (M1)', async () => { + const existingItem = { + id: 'item-99', + item_id: '42', + stage_id: 'stage-1', + pipeline_id: 'p1', + type: 'conversation', + is_lead: false, + created_at: '', + updated_at: '', + }; + const pipeline = makePipeline( + 'p1', + [ + { id: 'stage-1', name: 'Lead' }, + { id: 'stage-2', name: 'Qualified' }, + ], + [existingItem], + ); + + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never); + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([pipeline]); + vi.mocked(pipelinesService.moveItem).mockResolvedValue({ success: true, message: '' }); + + render(); + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); + + const user = userEvent.setup(); + await openPipelineAndSelectStage(user, 'Pipeline p1', 'Qualified'); + + await waitFor(() => { + expect(pipelinesService.moveItem).toHaveBeenCalledWith({ + pipeline_id: 'p1', + item_id: 'item-99', + from_stage_id: 'stage-1', + to_stage_id: 'stage-2', + }); + expect(pipelinesService.addItemToPipeline).not.toHaveBeenCalled(); + }); + }); + + it('removes from old pipeline before adding to new pipeline (C1)', async () => { + const existingItem = { + id: 'item-old', + item_id: '42', + stage_id: 'stage-A', + pipeline_id: 'p-old', + type: 'conversation', + is_lead: false, + created_at: '', + updated_at: '', + }; + const oldPipeline = makePipeline('p-old', [{ id: 'stage-A', name: 'StageOld' }], [existingItem]); + const newPipeline = makePipeline('p-new', [{ id: 'stage-B', name: 'StageNew' }]); + + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [oldPipeline, newPipeline] } as never); + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([oldPipeline]); + vi.mocked(pipelinesService.removeItemFromPipeline).mockResolvedValue({ success: true, message: '' }); + vi.mocked(pipelinesService.addItemToPipeline).mockResolvedValue({} as never); + + render(); + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); + + const user = userEvent.setup(); + await openPipelineAndSelectStage(user, 'Pipeline p-new', 'StageNew'); + + await waitFor(() => { + expect(pipelinesService.removeItemFromPipeline).toHaveBeenCalledWith('p-old', 'item-old'); + expect(pipelinesService.addItemToPipeline).toHaveBeenCalledWith('p-new', { + item_id: '42', + type: 'conversation', + pipeline_stage_id: 'stage-B', + }); + }); + }); + + it('dispatches updateConversation after pipeline action to refresh badge (C1)', async () => { + const pipeline = makePipeline('p1', [{ id: 'stage-1', name: 'Lead' }]); + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never); + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([]); + vi.mocked(pipelinesService.addItemToPipeline).mockResolvedValue({} as never); + const updatedConv = makeConversation('42'); + vi.mocked(chatService.getConversation).mockResolvedValue({ data: updatedConv } as never); + + render(); + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); + + const user = userEvent.setup(); + await openPipelineAndSelectStage(user, 'Pipeline p1', 'Lead'); + + await waitFor(() => { + expect(mockUpdateConversation).toHaveBeenCalledWith(updatedConv); + }); + }); + + it('removes ALL pipelines when conversation is in 2+ pipelines before adding to new one (H1)', async () => { + const makeItem = (id: string, pipelineId: string) => ({ + id, + item_id: '42', + stage_id: `stage-${pipelineId}`, + pipeline_id: pipelineId, + type: 'conversation', + is_lead: false, + created_at: '', + updated_at: '', + }); + const pOld1 = makePipeline('p-old1', [{ id: 'stage-p-old1', name: 'StageA' }], [makeItem('item-1', 'p-old1')]); + const pOld2 = makePipeline('p-old2', [{ id: 'stage-p-old2', name: 'StageB' }], [makeItem('item-2', 'p-old2')]); + const pNew = makePipeline('p-new', [{ id: 'stage-new', name: 'StageC' }]); + + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pOld1, pOld2, pNew] } as never); + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([pOld1, pOld2]); + vi.mocked(pipelinesService.removeItemFromPipeline).mockResolvedValue({ success: true, message: '' }); + vi.mocked(pipelinesService.addItemToPipeline).mockResolvedValue({} as never); + + render(); + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); + + const user = userEvent.setup(); + await openPipelineAndSelectStage(user, 'Pipeline p-new', 'StageC'); + + await waitFor(() => { + expect(pipelinesService.removeItemFromPipeline).toHaveBeenCalledWith('p-old1', 'item-1'); + expect(pipelinesService.removeItemFromPipeline).toHaveBeenCalledWith('p-old2', 'item-2'); + expect(pipelinesService.removeItemFromPipeline).toHaveBeenCalledTimes(2); + expect(pipelinesService.addItemToPipeline).toHaveBeenCalledWith('p-new', { + item_id: '42', + type: 'conversation', + pipeline_stage_id: 'stage-new', + }); + }); + }); + + it('shows loading label in stage submenu while getPipelinesByConversation is pending (isLoadingConvPipelines guard)', async () => { + const pipeline = makePipeline('p1', [{ id: 'stage-1', name: 'Lead' }]); + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never); + vi.mocked(pipelinesService.getPipelinesByConversation).mockReturnValue(new Promise(() => {})); + + render(); + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); + + const user = userEvent.setup(); + const menuTrigger = document.querySelector('[data-slot="dropdown-menu-trigger"]')!; + await user.click(menuTrigger); + + const addToTrigger = await screen.findByText('pipeline.addTo'); + const addToSubTrigger = (addToTrigger.closest('[data-slot="dropdown-menu-sub-trigger"]') ?? addToTrigger) as HTMLElement; + addToSubTrigger.focus(); + await user.keyboard('{ArrowRight}'); + + await waitFor(() => screen.getByText('Pipeline p1'), { timeout: 2000 }); + const pipelineEl = screen.getByText('Pipeline p1'); + const pipelineSubTrigger = (pipelineEl.closest('[data-slot="dropdown-menu-sub-trigger"]') ?? pipelineEl) as HTMLElement; + pipelineSubTrigger.focus(); + await user.keyboard('{ArrowRight}'); + + await waitFor(() => { + expect(screen.getByText('pipeline.loading')).toBeInTheDocument(); + expect(screen.queryByText('Lead')).not.toBeInTheDocument(); + }); + }); + + it('shows pipeline.moveError toast when move fails (M2 - distinct from addError)', async () => { + const existingItem = { + id: 'item-99', + item_id: '42', + stage_id: 'stage-1', + pipeline_id: 'p1', + type: 'conversation', + is_lead: false, + created_at: '', + updated_at: '', + }; + const pipeline = makePipeline( + 'p1', + [ + { id: 'stage-1', name: 'Lead' }, + { id: 'stage-2', name: 'Qualified' }, + ], + [existingItem], + ); + + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never); + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([pipeline]); + vi.mocked(pipelinesService.moveItem).mockRejectedValue(new Error('Server error')); + + render(); + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); + + const user = userEvent.setup(); + await openPipelineAndSelectStage(user, 'Pipeline p1', 'Qualified'); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('pipeline.moveError'); + expect(toast.error).not.toHaveBeenCalledWith('pipeline.addError'); + }); + }); +}); diff --git a/src/components/chat/chat-header/ChatHeader.tsx b/src/components/chat/chat-header/ChatHeader.tsx index 9a9914b7..9f1a53e5 100644 --- a/src/components/chat/chat-header/ChatHeader.tsx +++ b/src/components/chat/chat-header/ChatHeader.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; import { Button } from '@evoapi/design-system/button'; import { ArrowLeft, @@ -20,6 +21,8 @@ import { Unlock, Pin, Archive, + GitBranch, + Check, } from 'lucide-react'; import { DropdownMenu, @@ -27,13 +30,22 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuLabel, } from '@evoapi/design-system/dropdown-menu'; import { Conversation } from '@/types/chat/api'; +import type { Pipeline, PipelineStage } from '@/types/analytics'; import ContactAvatar from '@/components/chat/contact/ContactAvatar'; import { getStatusLabel, isPendingStatus } from '@/utils/chat/conversationStatus'; import { isPhoneBearingChannel } from '@/utils/channelUtils'; import { formatContactPhone } from '@/utils/contact/formatContactPhone'; import { useLanguage } from '@/hooks/useLanguage'; +import { useChatContext } from '@/contexts/chat/ChatContext'; +import { pipelinesService } from '@/services/pipelines/pipelinesService'; +import chatService from '@/services/chat/chatService'; +import { toast } from 'sonner'; interface ChatHeaderProps { conversation: Conversation; @@ -61,6 +73,10 @@ interface ChatHeaderProps { unreadCount: number; } +interface ConvPipelineData { + pipelines: Pipeline[]; +} + const ChatHeader = ({ conversation, onBackClick, @@ -84,6 +100,7 @@ const ChatHeader = ({ unreadCount, }: ChatHeaderProps) => { const { t } = useLanguage('chat'); + const chatContext = useChatContext(); const currentStatus = conversation.status; const hasUnreadMessages = unreadCount > 0; const isPinned = Boolean(conversation.custom_attributes?.pinned); @@ -94,9 +111,237 @@ const ChatHeader = ({ ? formatContactPhone(conversation.contact?.phone_number) : null; + const [menuOpen, setMenuOpen] = useState(false); + const [allPipelines, setAllPipelines] = useState([]); + const [isLoadingPipelines, setIsLoadingPipelines] = useState(false); + const [pipelinesLoaded, setPipelinesLoaded] = useState(false); + const [pipelinesLoadFailed, setPipelinesLoadFailed] = useState(false); + const [convPipelineData, setConvPipelineData] = useState(null); + const [isLoadingConvPipelines, setIsLoadingConvPipelines] = useState(false); + const pipelineFetchCountRef = useRef(0); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const resp = await pipelinesService.getPipelines({ is_active: true }); + if (!cancelled) { + setAllPipelines(resp.data ?? []); + setPipelinesLoaded(true); + } + } catch { + if (!cancelled) setPipelinesLoadFailed(true); + } + })(); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + setConvPipelineData(null); + }, [conversation.id]); + + useEffect(() => { + if (!menuOpen) return; + + const fetchId = ++pipelineFetchCountRef.current; + setIsLoadingConvPipelines(true); + (async () => { + try { + const pipelines = await pipelinesService.getPipelinesByConversation( + String(conversation.id), + ); + if (pipelineFetchCountRef.current !== fetchId) return; + setConvPipelineData({ pipelines }); + } catch { + if (pipelineFetchCountRef.current === fetchId) { + setConvPipelineData({ pipelines: [] }); + } + } finally { + if (pipelineFetchCountRef.current === fetchId) { + setIsLoadingConvPipelines(false); + } + } + })(); + }, [menuOpen, conversation.id]); + + const refreshConversationBadge = useCallback(async () => { + try { + const raw = await chatService.getConversation(String(conversation.id)); + const envelope = raw as unknown as { data?: Conversation } | null; + const updated: Conversation | null = envelope?.data ?? (raw as unknown as Conversation); + if (updated) { + chatContext.conversations.updateConversation(updated); + const pipelines = await pipelinesService.getPipelinesByConversation( + String(conversation.id), + ); + setConvPipelineData({ pipelines }); + } + } catch { + // badge refresh is best-effort + } + }, [conversation.id, chatContext]); + + const handlePipelineStageSelect = useCallback( + async (pipeline: Pipeline, stage: PipelineStage) => { + const currentPipelines = convPipelineData?.pipelines ?? []; + const existingInSamePipeline = currentPipelines.find(p => p.id === pipeline.id); + const existingInOtherPipelines = currentPipelines.filter(p => p.id !== pipeline.id); + + if (existingInSamePipeline) { + const item = existingInSamePipeline.items?.find( + i => String(i.item_id) === String(conversation.id), + ); + const itemId = item?.id; + if (!itemId) return; + try { + await pipelinesService.moveItem({ + pipeline_id: pipeline.id, + item_id: itemId, + from_stage_id: item.stage_id, + to_stage_id: stage.id, + }); + toast.success(t('pipeline.moveSuccess')); + await refreshConversationBadge(); + } catch { + toast.error(t('pipeline.moveError')); + } + } else { + if (existingInOtherPipelines.length > 0) { + const removeResults = await Promise.allSettled( + existingInOtherPipelines.map(p => { + const item = p.items?.find(i => String(i.item_id) === String(conversation.id)); + return item?.id + ? pipelinesService.removeItemFromPipeline(p.id, item.id) + : Promise.resolve(); + }), + ); + if (removeResults.some(r => r.status === 'rejected')) { + toast.error(t('pipeline.removeError')); + return; + } + } + try { + await pipelinesService.addItemToPipeline(pipeline.id, { + item_id: String(conversation.id), + type: 'conversation', + pipeline_stage_id: stage.id, + }); + toast.success(t('pipeline.addSuccess')); + await refreshConversationBadge(); + } catch { + toast.error(t('pipeline.addError')); + } + } + }, + [convPipelineData, conversation.id, t, refreshConversationBadge], + ); + + const handleRemoveFromPipeline = useCallback( + async (pipeline: Pipeline) => { + const item = pipeline.items?.find(i => String(i.item_id) === String(conversation.id)); + const itemId = item?.id; + if (!itemId) return; + try { + await pipelinesService.removeItemFromPipeline(pipeline.id, itemId); + toast.success(t('pipeline.removeSuccess')); + await refreshConversationBadge(); + } catch { + toast.error(t('pipeline.removeError')); + } + }, + [conversation.id, t, refreshConversationBadge], + ); + + const renderPipelineSubmenuContent = () => { + if (pipelinesLoadFailed) { + return ( + { + setPipelinesLoadFailed(false); + setIsLoadingPipelines(true); + try { + const resp = await pipelinesService.getPipelines({ is_active: true }); + setAllPipelines(resp.data ?? []); + setPipelinesLoaded(true); + } catch { + setPipelinesLoadFailed(true); + } finally { + setIsLoadingPipelines(false); + } + }} + > + {t('pipeline.loadError')} + + ); + } + + if (isLoadingPipelines || !pipelinesLoaded) { + return {t('pipeline.loading')}; + } + + if (allPipelines.length === 0) { + return {t('pipeline.noPipelines')}; + } + + const currentPipelines = convPipelineData?.pipelines ?? []; + + return ( + <> + {allPipelines.map(pipeline => ( + + + + {pipeline.name} + + + {isLoadingConvPipelines ? ( + {t('pipeline.loading')} + ) : ( + <> + {(pipeline.stages ?? []).map(stage => { + const convInThisPipeline = currentPipelines.find(p => p.id === pipeline.id); + const currentItem = convInThisPipeline?.items?.find( + i => String(i.item_id) === String(conversation.id), + ); + const isCurrentStage = currentItem?.stage_id === stage.id; + + return ( + handlePipelineStageSelect(pipeline, stage)} + className="flex items-center gap-2" + > + {isCurrentStage && } + {!isCurrentStage && } + {stage.name} + + ); + })} + {currentPipelines.some(p => p.id === pipeline.id) && ( + <> + + handleRemoveFromPipeline(pipeline)} + className="flex items-center gap-2 text-destructive focus:text-destructive" + > + + {t('pipeline.removeFrom')} + + + )} + + )} + + + ))} + + ); + }; + const renderConversationStatusDropdown = () => { return ( - + - ), -})); - -vi.mock('@evoapi/design-system/select', () => ({ - Select: ({ children, value, onValueChange }: any) => ( -
- {typeof children === 'function' ? children({ onValueChange }) : children} -
- ), - SelectTrigger: ({ children, ...props }: any) =>
{children}
, - SelectValue: ({ placeholder }: any) => {placeholder}, - SelectContent: ({ children }: any) =>
{children}
, - SelectItem: ({ children, value, onClick }: any) => ( - - ), -})); - -vi.mock('@evoapi/design-system/label', () => ({ - Label: ({ children, ...props }: any) => , -})); - -vi.mock('@evoapi/design-system/alert-dialog', () => ({ - AlertDialog: ({ children, open }: any) => open ?
{children}
: null, - AlertDialogContent: ({ children }: any) =>
{children}
, - AlertDialogHeader: ({ children }: any) =>
{children}
, - AlertDialogTitle: ({ children }: any) =>

{children}

, - AlertDialogDescription: ({ children }: any) =>

{children}

, - AlertDialogFooter: ({ children }: any) =>
{children}
, - AlertDialogAction: ({ children, onClick }: any) => ( - - ), - AlertDialogCancel: ({ children }: any) => , -})); - -vi.mock('lucide-react', () => ({ - Loader2: () => , - GitBranch: () => , - Trash2: () => , - Save: () => , - AlertTriangle: () => , -})); - -const CONVERSATION_ID = 'conv-123'; -const PIPELINE_ID = 'pipeline-1'; -const STAGE_1_ID = 'stage-1'; -const STAGE_2_ID = 'stage-2'; -const ITEM_ID = 'item-1'; - -function createPipeline(overrides?: Partial): Pipeline { - return { - id: PIPELINE_ID, - name: 'Sales Pipeline', - pipeline_type: 'sales', - visibility: 'public', - is_active: true, - created_at: '2026-01-01', - updated_at: '2026-01-01', - stages: [ - { - id: STAGE_1_ID, - name: 'Lead', - color: '#blue', - position: 0, - created_at: '2026-01-01', - updated_at: '2026-01-01', - items: [ - { - id: ITEM_ID, - item_id: CONVERSATION_ID, - type: 'conversation' as const, - pipeline_id: PIPELINE_ID, - stage_id: STAGE_1_ID, - is_lead: false, - created_at: '2026-01-01', - updated_at: '2026-01-01', - tasks_info: { - pending_count: 0, - overdue_count: 0, - due_soon_count: 0, - completed_count: 0, - total_count: 0, - }, - }, - ], - }, - { - id: STAGE_2_ID, - name: 'Qualified', - color: '#green', - position: 1, - created_at: '2026-01-01', - updated_at: '2026-01-01', - items: [], - }, - ], - ...overrides, - } as Pipeline; -} - -describe('PipelineManagement', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockGetPipelines.mockResolvedValue({ data: [createPipeline()] }); - }); - - it('renders without crashing', () => { - render( - , - ); - expect(screen.getByText('contactSidebar.pipeline.label')).toBeTruthy(); - }); - - describe('confirmSave — existing item (stage move)', () => { - it('calls moveItem with correct params when conversation already in pipeline', async () => { - const onPipelineUpdated = vi.fn(); - const pipeline = createPipeline(); - - const { container } = render( - , - ); - - // Wait for initial data load - await waitFor(() => { - expect(mockGetPipelines).toHaveBeenCalled(); - }); - - // Directly test the confirmSave logic by simulating the internal state: - // The component sets selectedPipelineId and selectedStageId from loadData. - // We trigger save button click which opens the confirm dialog. - const saveButtons = container.querySelectorAll('button'); - const saveButton = Array.from(saveButtons).find( - btn => btn.textContent?.includes('contactSidebar.pipeline.save') - ); - - // The save button should exist - expect(saveButton).toBeTruthy(); - }); - - it('uses moveItem (move_to_stage) not updateItemInPipeline for existing items', async () => { - // This test validates the core bug fix: - // When a conversation is already in a pipeline, changing stage should use - // moveItem() (same endpoint as Kanban drag-and-drop) instead of updateItemInPipeline() - const pipeline = createPipeline(); - - // Simulate the confirmSave logic directly - // Find pipeline item by searching through stages - const conversationPipeline = pipeline; - let conversationItem: any; - if (conversationPipeline?.stages) { - for (const stage of conversationPipeline.stages) { - const found = stage.items?.find( - (item: any) => String(item.item_id) === CONVERSATION_ID - ); - if (found) { - conversationItem = found; - break; - } - } - } - - // The item MUST be found with the correct search logic - expect(conversationItem).toBeDefined(); - expect(conversationItem.id).toBe(ITEM_ID); - expect(conversationItem.item_id).toBe(CONVERSATION_ID); - expect(conversationItem.stage_id).toBe(STAGE_1_ID); - - // Simulate what confirmSave does for existing items - await mockMoveItem({ - item_id: conversationItem.id, - pipeline_id: PIPELINE_ID, - from_stage_id: conversationItem.stage_id, - to_stage_id: STAGE_2_ID, - }); - - expect(mockMoveItem).toHaveBeenCalledWith({ - item_id: ITEM_ID, - pipeline_id: PIPELINE_ID, - from_stage_id: STAGE_1_ID, - to_stage_id: STAGE_2_ID, - }); - }); - - it('OLD BUG: item.id !== stage_id so old logic would never find the item', () => { - // This test documents the bug that was fixed. - // The old code compared item.id with selectedStageId which are unrelated IDs. - const pipeline = createPipeline(); - const selectedStageId = STAGE_2_ID; - - // OLD buggy logic: items?.find(item => item.id === selectedStageId && item.item_id === conversationId) - const oldResult = pipeline.items?.find( - (item: any) => String(item.id) === selectedStageId && String(item.item_id) === CONVERSATION_ID - ); - // pipeline.items is undefined (items are nested in stages), so this would be undefined - expect(oldResult).toBeUndefined(); - - // Even if we checked conversationPipeline.items, item.id (ITEM_ID='item-1') !== selectedStageId ('stage-2') - // So the old code would NEVER find an existing item, always falling through to addItemToPipeline - }); - }); - - describe('confirmSave — new item (add to pipeline)', () => { - it('calls addItemToPipeline when conversation is NOT in the pipeline', async () => { - // Pipeline with no items matching our conversation - const emptyPipeline = createPipeline({ - stages: [ - { - id: STAGE_1_ID, - name: 'Lead', - color: '#blue', - position: 0, - created_at: '2026-01-01', - updated_at: '2026-01-01', - items: [], - }, - ], - }); - - // Simulate the search logic - should NOT find an item - let conversationItem: any; - if (emptyPipeline.stages) { - for (const stage of emptyPipeline.stages) { - const found = stage.items?.find( - (item: any) => String(item.item_id) === CONVERSATION_ID - ); - if (found) { - conversationItem = found; - break; - } - } - } - - expect(conversationItem).toBeUndefined(); - - // When no item found, addItemToPipeline should be called - await mockAddItemToPipeline(PIPELINE_ID, { - item_id: CONVERSATION_ID, - type: 'conversation', - pipeline_stage_id: STAGE_1_ID, - }); - - expect(mockAddItemToPipeline).toHaveBeenCalledWith(PIPELINE_ID, { - item_id: CONVERSATION_ID, - type: 'conversation', - pipeline_stage_id: STAGE_1_ID, - }); - }); - }); -}); diff --git a/src/components/chat/contact-sidebar/PipelineManagement.tsx b/src/components/chat/contact-sidebar/PipelineManagement.tsx deleted file mode 100644 index 38f4e0ad..00000000 --- a/src/components/chat/contact-sidebar/PipelineManagement.tsx +++ /dev/null @@ -1,411 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Button } from '@evoapi/design-system/button'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@evoapi/design-system/select'; -import { Label } from '@evoapi/design-system/label'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@evoapi/design-system/alert-dialog'; -import { Loader2, GitBranch, Trash2, Save, AlertTriangle } from 'lucide-react'; -import { toast } from 'sonner'; -import { pipelinesService } from '@/services/pipelines/pipelinesService'; -import type { Pipeline, PipelineItem, PipelineStage } from '@/types/analytics'; -import { useLanguage } from '@/hooks/useLanguage'; - -interface PipelineManagementProps { - conversationId: string; - pipelines: Pipeline[]; - onPipelineUpdated?: () => void; -} - -interface ConversationPipelineData { - pipeline: Pipeline; - stage: PipelineStage; -} - -const PipelineManagement: React.FC = ({ - conversationId, - pipelines, - onPipelineUpdated, -}) => { - const { t } = useLanguage('chat'); - const [currentPipeline, setCurrentPipeline] = useState(null); - const [selectedPipelineId, setSelectedPipelineId] = useState(''); - const [selectedStageId, setSelectedStageId] = useState(''); - const [isSaving, setIsSaving] = useState(false); - const [showSaveConfirm, setShowSaveConfirm] = useState(false); - const [showRemoveConfirm, setShowRemoveConfirm] = useState(false); - const [originalPipelines, setOriginalPipelines] = useState([]); - - const loadData = async () => { - const pipelinesResponse = await pipelinesService.getPipelines(); - setOriginalPipelines(pipelinesResponse.data || []); - - let foundPipeline: ConversationPipelineData | null = null; - - for (const pipeline of pipelines) { - if (pipeline.stages) { - // Procurar o item da conversation nos stages - for (const stage of pipeline.stages) { - if (stage.items && stage.items.length > 0) { - foundPipeline = { pipeline, stage }; - setSelectedPipelineId(String(pipeline.id)); - setSelectedStageId(String(stage.id)); - break; - } - } - } - } - - if (!foundPipeline) return; - - setCurrentPipeline(foundPipeline); - }; - - useEffect(() => { - loadData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [conversationId, pipelines]); - - const selectedPipeline = pipelines.find(p => String(p.id) === selectedPipelineId); - - // Load stages when pipeline is selected - const [availableStages, setAvailableStages] = useState([]); - - useEffect(() => { - const loadStages = async () => { - if (!selectedPipelineId) { - setAvailableStages([]); - return; - } - - try { - const pipelinesToSearch = originalPipelines || []; - const selectedPipeline = pipelinesToSearch.find( - (p) => String(p.id) === selectedPipelineId, - ); - - setAvailableStages(selectedPipeline?.stages || []); - } catch (error) { - console.error('Error loading stages:', error); - setAvailableStages([]); - } - }; - - loadStages(); - }, [selectedPipelineId, originalPipelines]); - - const handlePipelineChange = (value: string) => { - setSelectedPipelineId(value); - // Reset stage when pipeline changes - setSelectedStageId(''); - }; - - const handleSaveClick = () => { - if (!selectedPipelineId || !selectedStageId) { - toast.error(t('contactSidebar.pipeline.selectError')); - return; - } - setShowSaveConfirm(true); - }; - - const confirmSave = async () => { - if (!selectedPipelineId || !selectedStageId) return; - - try { - setIsSaving(true); - setShowSaveConfirm(false); - - // Find the pipeline item for this conversation by searching through stages - const conversationPipeline = pipelines?.find( - (pipeline: Pipeline) => String(pipeline.id) === selectedPipelineId - ); - - let conversationItem: PipelineItem | undefined; - if (conversationPipeline?.stages) { - for (const stage of conversationPipeline.stages) { - const found = stage.items?.find( - (item: PipelineItem) => String(item.item_id) === conversationId - ); - if (found) { - conversationItem = found; - break; - } - } - } - - if (conversationItem) { - // Use move_to_stage endpoint (same as Kanban drag-and-drop) - await pipelinesService.moveItem({ - item_id: conversationItem.id, - pipeline_id: selectedPipelineId, - from_stage_id: conversationItem.stage_id, - to_stage_id: selectedStageId, - }); - } else { - await pipelinesService.addItemToPipeline(selectedPipelineId, { - item_id: conversationId, - type: 'conversation', - pipeline_stage_id: selectedStageId, - }); - } - - toast.success(t('contactSidebar.pipeline.saveSuccess')); - await loadData(); - onPipelineUpdated?.(); - } catch (error) { - console.error('Error updating pipeline:', error); - toast.error(t('contactSidebar.pipeline.saveError')); - } finally { - setIsSaving(false); - } - }; - - const handleRemoveClick = () => { - setShowRemoveConfirm(true); - }; - - const confirmRemove = async () => { - if (!currentPipeline) return; - - try { - setIsSaving(true); - setShowRemoveConfirm(false); - await pipelinesService.removeItemFromPipeline( - String(currentPipeline.pipeline.id), - conversationId, - ); - toast.success(t('contactSidebar.pipeline.removeSuccess')); - setSelectedPipelineId(''); - setSelectedStageId(''); - setCurrentPipeline(null); - onPipelineUpdated?.(); - } catch (error) { - console.error('Error removing from pipeline:', error); - toast.error(t('contactSidebar.pipeline.removeError')); - } finally { - setIsSaving(false); - } - }; - - return ( -
- {/* Current Pipeline Status */} - {currentPipeline && ( -
-
- - {t('contactSidebar.pipeline.current')} -
-

- {currentPipeline.pipeline.name} • {currentPipeline.stage.name} -

-
- )} - - {/* Pipeline Selection */} -
- - -
- - {/* Stage Selection */} - {selectedPipelineId && ( -
- - -
- )} - - {/* Actions */} -
- - {currentPipeline && ( - - )} -
- - {/* Save Confirmation Dialog */} - - - -
-
- -
-
- - {currentPipeline - ? t('contactSidebar.pipeline.dialogs.update.title') - : t('contactSidebar.pipeline.dialogs.add.title')} - - - {currentPipeline ? ( - <> - {(() => { - const stageName = - availableStages.find(s => String(s.id) === selectedStageId)?.name || ''; - const parts = t('contactSidebar.pipeline.dialogs.update.description', { - pipelineName: selectedPipeline?.name || '', - stageName: stageName, - }).split(selectedPipeline?.name || ''); - const stageParts = parts[1]?.split(stageName) || []; - return ( - <> - {parts[0]} - {selectedPipeline?.name} - {stageParts[0]} - {stageName} - {stageParts[1]} - - ); - })()} - - ) : ( - <> - {(() => { - const stageName = - availableStages.find(s => String(s.id) === selectedStageId)?.name || ''; - const parts = t('contactSidebar.pipeline.dialogs.add.description', { - pipelineName: selectedPipeline?.name || '', - stageName: stageName, - }).split(selectedPipeline?.name || ''); - const stageParts = parts[1]?.split(stageName) || []; - return ( - <> - {parts[0]} - {selectedPipeline?.name} - {stageParts[0]} - {stageName} - {stageParts[1]} - - ); - })()} - - )} - -
-
-
- - - - {t('contactSidebar.pipeline.dialogs.remove.cancel')} - - - - {currentPipeline - ? t('contactSidebar.pipeline.dialogs.update.confirm') - : t('contactSidebar.pipeline.dialogs.add.confirm')} - - -
-
- - {/* Remove Confirmation Dialog */} - - - -
-
- -
-
- - {t('contactSidebar.pipeline.dialogs.remove.title')} - - - {(() => { - const pipelineName = currentPipeline?.pipeline.name || ''; - const description = t('contactSidebar.pipeline.dialogs.remove.description', { - pipelineName: pipelineName, - }); - const parts = description.split(pipelineName); - return ( - <> - {parts[0]} - {pipelineName} - {parts[1]} - - ); - })()} - -
-
-
- - - - {t('contactSidebar.pipeline.dialogs.remove.cancel')} - - - - {t('contactSidebar.pipeline.dialogs.remove.confirm')} - - -
-
-
- ); -}; - -export default PipelineManagement; diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 73b7de74..ae26ba59 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -456,40 +456,6 @@ "execute": "Execute Macro" } }, - "pipeline": { - "loading": "Error loading pipeline data", - "noPipelines": "No pipeline configured", - "current": "Current Pipeline", - "label": "Pipeline", - "selectPipeline": "Select a pipeline", - "selectStage": "Select a stage", - "stage": "Stage", - "saving": "Saving...", - "save": "Save", - "saveSuccess": "Pipeline updated successfully", - "saveError": "Error updating pipeline", - "selectError": "Select a pipeline and a stage", - "removeSuccess": "Conversation removed from pipeline", - "removeError": "Error removing from pipeline", - "dialogs": { - "update": { - "title": "Update Pipeline", - "description": "Are you sure you want to move this conversation to {{pipelineName}} at stage {{stageName}}?", - "confirm": "Update" - }, - "add": { - "title": "Add to Pipeline", - "description": "Are you sure you want to add this conversation to pipeline {{pipelineName}} at stage {{stageName}}?", - "confirm": "Add" - }, - "remove": { - "title": "Remove from Pipeline", - "description": "Are you sure you want to remove this conversation from pipeline {{pipelineName}}? This action cannot be undone.", - "cancel": "Cancel", - "confirm": "Remove" - } - } - }, "filters": { "assignee": { "title": "Conversation Assignment", @@ -959,5 +925,19 @@ "contactsEmpty": "No contacts found", "messagesEmpty": "No messages found" } + }, + "pipeline": { + "section": "Pipeline", + "addTo": "Add to pipeline", + "removeFrom": "Remove from pipeline", + "loading": "Loading pipelines...", + "noPipelines": "No pipelines available", + "loadError": "Error loading pipelines. Click to retry.", + "addSuccess": "Added to pipeline", + "moveSuccess": "Moved to stage", + "removeSuccess": "Removed from pipeline", + "addError": "Error adding to pipeline", + "moveError": "Error moving to stage", + "removeError": "Error removing from pipeline" } } diff --git a/src/i18n/locales/es/chat.json b/src/i18n/locales/es/chat.json index 6176e6c6..3a84750e 100644 --- a/src/i18n/locales/es/chat.json +++ b/src/i18n/locales/es/chat.json @@ -429,40 +429,6 @@ "execute": "Ejecutar Macro" } }, - "pipeline": { - "loading": "Error al cargar datos del pipeline", - "noPipelines": "No hay pipeline configurado", - "current": "Pipeline Actual", - "label": "Pipeline", - "selectPipeline": "Seleccione un pipeline", - "selectStage": "Seleccione una etapa", - "stage": "Etapa", - "saving": "Guardando...", - "save": "Guardar", - "saveSuccess": "Pipeline actualizado con éxito", - "saveError": "Error al actualizar pipeline", - "selectError": "Seleccione un pipeline y una etapa", - "removeSuccess": "Conversación eliminada del pipeline", - "removeError": "Error al eliminar del pipeline", - "dialogs": { - "update": { - "title": "Actualizar Pipeline", - "description": "¿Está seguro de que desea mover esta conversación a {{pipelineName}} en la etapa {{stageName}}?", - "confirm": "Actualizar" - }, - "add": { - "title": "Agregar al Pipeline", - "description": "¿Está seguro de que desea agregar esta conversación al pipeline {{pipelineName}} en la etapa {{stageName}}?", - "confirm": "Agregar" - }, - "remove": { - "title": "Eliminar del Pipeline", - "description": "¿Está seguro de que desea eliminar esta conversación del pipeline {{pipelineName}}? Esta acción no se puede deshacer.", - "cancel": "Cancelar", - "confirm": "Eliminar" - } - } - }, "filters": { "assignee": { "title": "Asignación de Conversación", @@ -894,5 +860,19 @@ "success": { "sent": "¡Plantilla enviada con éxito!" } + }, + "pipeline": { + "section": "Pipeline", + "addTo": "Agregar al pipeline", + "removeFrom": "Quitar del pipeline", + "loading": "Cargando pipelines...", + "noPipelines": "No hay pipelines disponibles", + "loadError": "Error al cargar pipelines. Haz clic para reintentar.", + "addSuccess": "Agregado al pipeline", + "moveSuccess": "Movido a la etapa", + "removeSuccess": "Quitado del pipeline", + "addError": "Error al agregar al pipeline", + "moveError": "Error al mover a la etapa", + "removeError": "Error al quitar del pipeline" } } diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json index f2201c0c..653c3ec0 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -429,40 +429,6 @@ "execute": "Exécuter la Macro" } }, - "pipeline": { - "loading": "Erreur lors du chargement des données du pipeline", - "noPipelines": "Aucun pipeline configuré", - "current": "Pipeline Actuel", - "label": "Pipeline", - "selectPipeline": "Sélectionnez un pipeline", - "selectStage": "Sélectionnez une étape", - "stage": "Étape", - "saving": "Enregistrement...", - "save": "Enregistrer", - "saveSuccess": "Pipeline mis à jour avec succès", - "saveError": "Erreur lors de la mise à jour du pipeline", - "selectError": "Sélectionnez un pipeline et une étape", - "removeSuccess": "Conversation supprimée du pipeline", - "removeError": "Erreur lors de la suppression du pipeline", - "dialogs": { - "update": { - "title": "Mettre à jour le Pipeline", - "description": "Êtes-vous sûr de vouloir déplacer cette conversation vers {{pipelineName}} à l'étape {{stageName}} ?", - "confirm": "Mettre à jour" - }, - "add": { - "title": "Ajouter au Pipeline", - "description": "Êtes-vous sûr de vouloir ajouter cette conversation au pipeline {{pipelineName}} à l'étape {{stageName}} ?", - "confirm": "Ajouter" - }, - "remove": { - "title": "Retirer du Pipeline", - "description": "Êtes-vous sûr de vouloir retirer cette conversation du pipeline {{pipelineName}} ? Cette action ne peut pas être annulée.", - "cancel": "Annuler", - "confirm": "Retirer" - } - } - }, "filters": { "assignee": { "title": "Attribution de Conversation", @@ -894,5 +860,19 @@ "success": { "sent": "Modèle envoyé avec succès !" } + }, + "pipeline": { + "section": "Pipeline", + "addTo": "Ajouter au pipeline", + "removeFrom": "Retirer du pipeline", + "loading": "Chargement des pipelines...", + "noPipelines": "Aucun pipeline disponible", + "loadError": "Erreur lors du chargement. Cliquez pour réessayer.", + "addSuccess": "Ajouté au pipeline", + "moveSuccess": "Déplacé vers l'étape", + "removeSuccess": "Retiré du pipeline", + "addError": "Erreur lors de l'ajout au pipeline", + "moveError": "Erreur lors du déplacement vers l'étape", + "removeError": "Erreur lors de la suppression du pipeline" } } diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index 0a12eb6f..5dd26f9a 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -429,40 +429,6 @@ "execute": "Eseguire Macro" } }, - "pipeline": { - "loading": "Errore nel caricamento dei dati della pipeline", - "noPipelines": "Nessuna pipeline configurata", - "current": "Pipeline Attuale", - "label": "Pipeline", - "selectPipeline": "Seleziona una pipeline", - "selectStage": "Seleziona uno stadio", - "stage": "Stadio", - "saving": "Salvataggio...", - "save": "Salva", - "saveSuccess": "Pipeline aggiornata con successo", - "saveError": "Errore nell'aggiornamento della pipeline", - "selectError": "Seleziona una pipeline e uno stadio", - "removeSuccess": "Conversazione rimossa dalla pipeline", - "removeError": "Errore nella rimozione dalla pipeline", - "dialogs": { - "update": { - "title": "Aggiorna Pipeline", - "description": "Sei sicuro di voler spostare questa conversazione a {{pipelineName}} nello stadio {{stageName}}?", - "confirm": "Aggiorna" - }, - "add": { - "title": "Aggiungi alla Pipeline", - "description": "Sei sicuro di voler aggiungere questa conversazione alla pipeline {{pipelineName}} nello stadio {{stageName}}?", - "confirm": "Aggiungi" - }, - "remove": { - "title": "Rimuovi dalla Pipeline", - "description": "Sei sicuro di voler rimuovere questa conversazione dalla pipeline {{pipelineName}}? Questa azione non può essere annullata.", - "cancel": "Annulla", - "confirm": "Rimuovi" - } - } - }, "filters": { "assignee": { "title": "Assegnazione Conversazione", @@ -894,5 +860,19 @@ "success": { "sent": "Modello inviato con successo!" } + }, + "pipeline": { + "section": "Pipeline", + "addTo": "Aggiungi al pipeline", + "removeFrom": "Rimuovi dal pipeline", + "loading": "Caricamento pipeline...", + "noPipelines": "Nessun pipeline disponibile", + "loadError": "Errore nel caricamento. Clicca per riprovare.", + "addSuccess": "Aggiunto al pipeline", + "moveSuccess": "Spostato nella fase", + "removeSuccess": "Rimosso dal pipeline", + "addError": "Errore nell'aggiunta al pipeline", + "moveError": "Errore nello spostamento nella fase", + "removeError": "Errore nella rimozione dal pipeline" } } diff --git a/src/i18n/locales/pt-BR/chat.json b/src/i18n/locales/pt-BR/chat.json index b66341f1..84b7550b 100644 --- a/src/i18n/locales/pt-BR/chat.json +++ b/src/i18n/locales/pt-BR/chat.json @@ -456,40 +456,6 @@ "execute": "Executar Macro" } }, - "pipeline": { - "loading": "Erro ao carregar dados do pipeline", - "noPipelines": "Nenhum pipeline configurado", - "current": "Pipeline Atual", - "label": "Pipeline", - "selectPipeline": "Selecione um pipeline", - "selectStage": "Selecione um estágio", - "stage": "Estágio", - "saving": "Salvando...", - "save": "Salvar", - "saveSuccess": "Pipeline atualizado com sucesso", - "saveError": "Erro ao atualizar pipeline", - "selectError": "Selecione um pipeline e um estágio", - "removeSuccess": "Conversa removida do pipeline", - "removeError": "Erro ao remover do pipeline", - "dialogs": { - "update": { - "title": "Atualizar Pipeline", - "description": "Você tem certeza que deseja mover esta conversa para {{pipelineName}} no estágio {{stageName}}?", - "confirm": "Atualizar" - }, - "add": { - "title": "Adicionar ao Pipeline", - "description": "Você tem certeza que deseja adicionar esta conversa ao pipeline {{pipelineName}} no estágio {{stageName}}?", - "confirm": "Adicionar" - }, - "remove": { - "title": "Remover do Pipeline", - "description": "Você tem certeza que deseja remover esta conversa do pipeline {{pipelineName}}? Esta ação não pode ser desfeita.", - "cancel": "Cancelar", - "confirm": "Remover" - } - } - }, "filters": { "assignee": { "title": "Atribuição de Conversa", @@ -965,5 +931,19 @@ "contactsEmpty": "Nenhum contato encontrado", "messagesEmpty": "Nenhuma mensagem encontrada" } + }, + "pipeline": { + "section": "Pipeline", + "addTo": "Adicionar ao pipeline", + "removeFrom": "Remover do pipeline", + "loading": "Carregando pipelines...", + "noPipelines": "Nenhum pipeline disponível", + "loadError": "Erro ao carregar pipelines. Clique para tentar novamente.", + "addSuccess": "Adicionado ao pipeline", + "moveSuccess": "Movido para o estágio", + "removeSuccess": "Removido do pipeline", + "addError": "Erro ao adicionar ao pipeline", + "moveError": "Erro ao mover para o estágio", + "removeError": "Erro ao remover do pipeline" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/pt/chat.json b/src/i18n/locales/pt/chat.json index 5fd18c35..973eb809 100644 --- a/src/i18n/locales/pt/chat.json +++ b/src/i18n/locales/pt/chat.json @@ -429,40 +429,6 @@ "execute": "Executar Macro" } }, - "pipeline": { - "loading": "Erro ao carregar dados do pipeline", - "noPipelines": "Nenhum pipeline configurado", - "current": "Pipeline Atual", - "label": "Pipeline", - "selectPipeline": "Selecione um pipeline", - "selectStage": "Selecione um estágio", - "stage": "Estágio", - "saving": "Salvando...", - "save": "Salvar", - "saveSuccess": "Pipeline atualizado com sucesso", - "saveError": "Erro ao atualizar pipeline", - "selectError": "Selecione um pipeline e um estágio", - "removeSuccess": "Conversa removida do pipeline", - "removeError": "Erro ao remover do pipeline", - "dialogs": { - "update": { - "title": "Atualizar Pipeline", - "description": "Você tem certeza que deseja mover esta conversa para {{pipelineName}} no estágio {{stageName}}?", - "confirm": "Atualizar" - }, - "add": { - "title": "Adicionar ao Pipeline", - "description": "Você tem certeza que deseja adicionar esta conversa ao pipeline {{pipelineName}} no estágio {{stageName}}?", - "confirm": "Adicionar" - }, - "remove": { - "title": "Remover do Pipeline", - "description": "Você tem certeza que deseja remover esta conversa do pipeline {{pipelineName}}? Esta ação não pode ser desfeita.", - "cancel": "Cancelar", - "confirm": "Remover" - } - } - }, "filters": { "assignee": { "title": "Atribuição de Conversa", @@ -900,5 +866,19 @@ "success": { "sent": "Template enviado com sucesso!" } + }, + "pipeline": { + "section": "Pipeline", + "addTo": "Adicionar ao pipeline", + "removeFrom": "Remover do pipeline", + "loading": "Carregando pipelines...", + "noPipelines": "Nenhum pipeline disponível", + "loadError": "Erro ao carregar pipelines. Clique para tentar novamente.", + "addSuccess": "Adicionado ao pipeline", + "moveSuccess": "Movido para o estágio", + "removeSuccess": "Removido do pipeline", + "addError": "Erro ao adicionar ao pipeline", + "moveError": "Erro ao mover para o estágio", + "removeError": "Erro ao remover do pipeline" } }