From 57dfb3a3aa1df7514ec072462e617acc1804109d Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 11 Jun 2026 09:08:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-office):=20ActivityTimeline=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20(=ED=95=84=ED=84=B0+?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=EC=8A=A4=ED=81=AC=EB=A1=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ActivityTimeline.jsx | 53 +++++++++++++++++++ .../components/ActivityTimeline.test.jsx | 45 ++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/pages/agent-office/components/ActivityTimeline.jsx create mode 100644 src/pages/agent-office/components/ActivityTimeline.test.jsx diff --git a/src/pages/agent-office/components/ActivityTimeline.jsx b/src/pages/agent-office/components/ActivityTimeline.jsx new file mode 100644 index 0000000..1ebd1db --- /dev/null +++ b/src/pages/agent-office/components/ActivityTimeline.jsx @@ -0,0 +1,53 @@ +// src/pages/agent-office/components/ActivityTimeline.jsx +import { useState, useRef, useEffect } from 'react'; +import { useActivityFeed } from '../hooks/useActivityFeed.js'; +import ActivityFilters from './ActivityFilters.jsx'; +import ActivityItem from './ActivityItem.jsx'; + +const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 }; + +export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) { + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger); + + const sentinelRef = useRef(null); + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + const io = new IntersectionObserver(entries => { + if (entries[0].isIntersecting) loadMore(); + }, { rootMargin: '120px' }); + io.observe(el); + return () => io.disconnect(); + }, [loadMore, items.length]); + + return ( +
+
+
팀 활동 ({total})
+
+ +
+ {error && ( +
+ 불러오기 실패: {error} + +
+ )} + {!error && items.length === 0 && !loading && ( +
최근 {filters.days}일 활동 없음
+ )} + {items.map((item, i) => ( + + ))} + {loading &&
불러오는 중…
} + {hasMore && !loading &&
} + {!hasMore && items.length > 0 &&
더 이상 활동 없음
} +
+
+ ); +} diff --git a/src/pages/agent-office/components/ActivityTimeline.test.jsx b/src/pages/agent-office/components/ActivityTimeline.test.jsx new file mode 100644 index 0000000..ba72d5a --- /dev/null +++ b/src/pages/agent-office/components/ActivityTimeline.test.jsx @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import ActivityTimeline from './ActivityTimeline.jsx'; + +// jsdom IntersectionObserver stub +beforeEach(() => { + global.IntersectionObserver = class { + observe() {} unobserve() {} disconnect() {} + }; +}); + +const mockAgentActivity = vi.fn(); +vi.mock('../../../api', () => ({ + agentActivity: (...args) => mockAgentActivity(...args), +})); + +describe('ActivityTimeline', () => { + it('항목을 렌더하고 헤더에 total을 표시한다', async () => { + mockAgentActivity.mockResolvedValueOnce({ + items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }], + total: 1, + }); + render( {}} />); + await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument()); + expect(screen.getByText(/팀 활동/)).toHaveTextContent('1'); + }); + + it('빈 결과면 안내 문구를 표시한다', async () => { + mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 }); + render( {}} />); + await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument()); + }); + + it('항목 클릭 시 onSelectAgent가 호출된다', async () => { + const onSelect = vi.fn(); + mockAgentActivity.mockResolvedValueOnce({ + items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }], + total: 1, + }); + render(); + const row = await screen.findByText('signal_check'); + fireEvent.click(row.closest('.ao-activity-item')); + expect(onSelect).toHaveBeenCalledWith('lotto'); + }); +});