diff --git a/src/pages/agent-office/hooks/useActivityFeed.js b/src/pages/agent-office/hooks/useActivityFeed.js new file mode 100644 index 0000000..893998e --- /dev/null +++ b/src/pages/agent-office/hooks/useActivityFeed.js @@ -0,0 +1,57 @@ +// src/pages/agent-office/hooks/useActivityFeed.js +import { useState, useEffect, useCallback, useRef } from 'react'; +import { agentActivity } from '../../../api'; + +const PAGE_SIZE = 30; + +export function useActivityFeed(filters, refreshTrigger = 0) { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const offsetRef = useRef(0); + const loadingRef = useRef(false); + const filtersRef = useRef(filters); + filtersRef.current = filters; + + const filterKey = JSON.stringify(filters); + + const fetchPage = useCallback(async (offset, replace) => { + if (loadingRef.current) return; + loadingRef.current = true; + setLoading(true); + setError(null); + try { + const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset }); + const newItems = Array.isArray(data?.items) ? data.items : []; + setTotal(data?.total || 0); + setItems(prev => (replace ? newItems : [...prev, ...newItems])); + offsetRef.current = offset + newItems.length; + } catch (e) { + setError(e.message || '불러오기 실패'); + } finally { + loadingRef.current = false; + setLoading(false); + } + }, []); + + useEffect(() => { + offsetRef.current = 0; + fetchPage(0, true); + }, [filterKey, refreshTrigger, fetchPage]); + + const loadMore = useCallback(() => { + if (loadingRef.current) return; + if (offsetRef.current >= total) return; + fetchPage(offsetRef.current, false); + }, [fetchPage, total]); + + const retry = useCallback(() => { + offsetRef.current = 0; + fetchPage(0, true); + }, [fetchPage]); + + const hasMore = items.length < total; + return { items, total, loading, error, hasMore, loadMore, retry }; +} diff --git a/src/pages/agent-office/hooks/useActivityFeed.test.js b/src/pages/agent-office/hooks/useActivityFeed.test.js new file mode 100644 index 0000000..4a1f52a --- /dev/null +++ b/src/pages/agent-office/hooks/useActivityFeed.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useActivityFeed } from './useActivityFeed.js'; + +const mockAgentActivity = vi.fn(); +vi.mock('../../../api', () => ({ + agentActivity: (...args) => mockAgentActivity(...args), +})); + +beforeEach(() => mockAgentActivity.mockReset()); + +const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over }); + +describe('useActivityFeed', () => { + it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => { + mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 }); + const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0)); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 })); + expect(result.current.total).toBe(1); + }); + + it('loadMore는 다음 offset으로 append한다', async () => { + mockAgentActivity + .mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 }) + .mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 }); + const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0)); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + await act(async () => { result.current.loadMore(); }); + await waitFor(() => expect(result.current.items).toHaveLength(2)); + expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 })); + }); + + it('필터 변경 시 offset 리셋 + items 교체', async () => { + mockAgentActivity + .mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 }) + .mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 }); + const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } }); + await waitFor(() => expect(result.current.items[0].task_id).toBe('a')); + rerender({ f: { days: 7, agent_id: 'insta' } }); + await waitFor(() => expect(result.current.items[0].task_id).toBe('c')); + expect(result.current.items).toHaveLength(1); + }); + + it('refreshTrigger 변경 시 첫 페이지 재조회', async () => { + mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 }); + const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } }); + await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1)); + rerender({ rt: 1 }); + await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2)); + }); + + it('hasMore는 items.length < total', async () => { + mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 }); + const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0)); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(result.current.hasMore).toBe(true); + }); +});