feat(agent-office): ActivityTimeline 컨테이너 (필터+무한스크롤)
This commit is contained in:
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="ao-sidepanel ao-activity">
|
||||||
|
<div className="ao-sidepanel-header ao-activity-header">
|
||||||
|
<div className="ao-sidepanel-name">팀 활동 ({total})</div>
|
||||||
|
</div>
|
||||||
|
<ActivityFilters filters={filters} onChange={setFilters} />
|
||||||
|
<div className="ao-sidepanel-content ao-activity-content">
|
||||||
|
{error && (
|
||||||
|
<div className="ao-activity-error">
|
||||||
|
불러오기 실패: {error}
|
||||||
|
<button type="button" onClick={retry}>재시도</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && items.length === 0 && !loading && (
|
||||||
|
<div className="ao-empty">최근 {filters.days}일 활동 없음</div>
|
||||||
|
)}
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<ActivityItem
|
||||||
|
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
|
||||||
|
item={item}
|
||||||
|
onSelectAgent={onSelectAgent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{loading && <div className="ao-activity-loading">불러오는 중…</div>}
|
||||||
|
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
|
||||||
|
{!hasMore && items.length > 0 && <div className="ao-activity-end">더 이상 활동 없음</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
@@ -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(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||||
|
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('빈 결과면 안내 문구를 표시한다', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
|
||||||
|
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||||
|
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(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
|
||||||
|
const row = await screen.findByText('signal_check');
|
||||||
|
fireEvent.click(row.closest('.ao-activity-item'));
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('lotto');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user