145 lines
4.3 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Segmented, Button, Spin, Flex } from 'antd';
import {
CalendarOutlined,
MailOutlined,
CompassOutlined,
UserAddOutlined,
MessageOutlined,
HistoryOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { ActivityFeedResult, ActivityItem } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
const TYPE_CONFIG: Record<ActivityItem['type'], { color: string; icon: React.ReactNode }> = {
shift_signup: { color: '#eb2f96', icon: <CalendarOutlined style={{ fontSize: 13 }} /> },
response_submitted: { color: '#faad14', icon: <MessageOutlined style={{ fontSize: 13 }} /> },
canvass_completed: { color: '#52c41a', icon: <CompassOutlined style={{ fontSize: 13 }} /> },
email_sent: { color: '#1890ff', icon: <MailOutlined style={{ fontSize: 13 }} /> },
user_created: { color: '#722ed1', icon: <UserAddOutlined style={{ fontSize: 13 }} /> },
};
const MODULE_OPTIONS = [
{ label: 'All', value: 'all' },
{ label: 'Map', value: 'map' },
{ label: 'Influence', value: 'influence' },
{ label: 'Users', value: 'users' },
];
function ActivityRow({ item }: { item: ActivityItem }) {
const config = TYPE_CONFIG[item.type];
return (
<Flex
align="center"
gap={8}
style={{
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.4,
}}
>
<span style={{ color: config.color, flexShrink: 0, width: 18, textAlign: 'center' }}>{config.icon}</span>
<Text strong style={{ fontSize: 13, flexShrink: 0 }}>{item.title}</Text>
<Text
type="secondary"
style={{
fontSize: 13,
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.description}
</Text>
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, whiteSpace: 'nowrap' }}>
{dayjs(item.timestamp).fromNow(true)}
</Text>
</Flex>
);
}
export default function ActivityFeedCard() {
const [items, setItems] = useState<ActivityItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [module, setModule] = useState('all');
const [loading, setLoading] = useState(true);
const fetchActivity = useCallback(async (p: number, mod: string, append: boolean) => {
setLoading(true);
try {
const res = await api.get<ActivityFeedResult>('/dashboard/activity', {
params: { page: p, limit: 15, module: mod },
});
if (append) {
setItems(prev => [...prev, ...res.data.items]);
} else {
setItems(res.data.items);
}
setTotal(res.data.total);
} catch {
// non-critical
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setPage(1);
fetchActivity(1, module, false);
}, [module, fetchActivity]);
const handleLoadMore = () => {
const next = page + 1;
setPage(next);
fetchActivity(next, module, true);
};
const hasMore = items.length < total;
return (
<Card
title={<span style={{ fontSize: 14 }}><HistoryOutlined style={{ marginRight: 6, fontSize: 15 }} />Recent Activity</span>}
size="small"
extra={
<Segmented
size="small"
value={module}
onChange={(val) => setModule(val as string)}
options={MODULE_OPTIONS}
/>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && items.length === 0 ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : items.length === 0 ? (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No recent activity</Text>
) : (
<>
<div>
{items.map(item => (
<ActivityRow key={item.id} item={item} />
))}
</div>
{hasMore && (
<div style={{ textAlign: 'center', paddingTop: 4 }}>
<Button size="small" type="link" onClick={handleLoadMore} loading={loading} style={{ fontSize: 13 }}>
Load more
</Button>
</div>
)}
</>
)}
</Card>
);
}