Files
makemd/dashboard/src/pages/Finance/Transactions.tsx

481 lines
16 KiB
TypeScript
Raw Normal View History

import {
useState,
useEffect,
useMemo,
Table,
Button,
Input,
Select,
DatePicker,
message,
Card,
Row,
Col,
Tag,
Space,
Modal,
Drawer,
Descriptions,
Typography,
Alert,
Statistic,
Tooltip,
SearchOutlined,
FilterOutlined,
ExportOutlined,
DownloadOutlined,
EyeOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
DollarOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
ReloadOutlined,
useNavigate,
Option,
RangePicker,
Search,
Title,
Text,
FC,
} from '@/imports';
import { LineChart, Line, Bar, Pie as RechartsPie, ResponsiveContainer, Legend, XAxis, YAxis, CartesianGrid, Cell } from 'recharts';
import moment from 'moment';
import { financeDataSource } from '@/services/financeDataSource';
import type { Transaction } from '@/services/financeDataSource';
const Transactions: FC = () => {
const navigate = useNavigate();
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRows, setSelectedRows] = useState<Transaction[]>([]);
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
const [currentTransaction, setCurrentTransaction] = useState<Transaction | null>(null);
const [filters, setFilters] = useState({
type: '',
category: '',
status: '',
search: '',
dateRange: null as any,
minAmount: null as number | null,
maxAmount: null as number | null,
});
const fetchTransactions = async (filterParams?: any) => {
setLoading(true);
try {
// 转换过滤参数以匹配 financeDataSource 期望的格式
const params = {
type: filterParams?.type || filters.type,
startDate: filterParams?.dateRange?.[0]?.format('YYYY-MM-DD'),
endDate: filterParams?.dateRange?.[1]?.format('YYYY-MM-DD'),
};
const data = await financeDataSource.fetchTransactions(params);
setTransactions(data);
} catch (error) {
message.error('Failed to load transactions');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTransactions();
}, []); // 只在组件挂载时执行一次
const handleExport = () => {
message.success('交易记录已导出');
};
const handleBatchExport = () => {
if (selectedRows.length === 0) {
message.warning('请先选择要导出的交易');
return;
}
message.success(`成功导出 ${selectedRows.length} 条交易记录`);
};
const handleViewDetail = (record: Transaction) => {
setCurrentTransaction(record);
setDetailDrawerVisible(true);
};
const handleRefresh = () => {
fetchTransactions();
message.success('数据已刷新');
};
const stats = useMemo(() => {
const income = transactions.filter(t => t.type === 'income').reduce((sum, t) => sum + t.amount, 0);
const expense = transactions.filter(t => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0);
const pending = transactions.filter(t => t.status === 'pending').reduce((sum, t) => sum + t.amount, 0);
return {
income,
expense,
profit: income - expense,
pending,
total: transactions.length,
};
}, [transactions]);
const trendData = useMemo(() => {
const trendData = Array.from({ length: 7 }, (_, i) => {
const date = moment().subtract(i, 'days').format('YYYY-MM-DD');
const dayTransactions = transactions.filter(t => moment(t.createdAt).format('YYYY-MM-DD') === date);
return {
date,
income: dayTransactions.filter(t => t.type === 'income').reduce((sum, t) => sum + t.amount, 0),
expense: dayTransactions.filter(t => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0),
};
}).reverse();
return trendData;
}, [transactions]);
const categoryData = useMemo(() => {
const categoryMap: Record<string, number> = {};
transactions.filter(t => t.type === 'expense').forEach(t => {
categoryMap[t.category] = (categoryMap[t.category] || 0) + t.amount;
});
return Object.entries(categoryMap).map(([name, value]) => ({ name, value }));
}, [transactions]);
const columns = [
{
title: '交易ID',
dataIndex: 'transactionId',
key: 'transactionId',
render: (text: string) => <Text strong>{text}</Text>,
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
sorter: (a: Transaction, b: Transaction) => a.amount - b.amount,
render: (amount: number, record: Transaction) => (
<Text style={{ color: record.type === 'income' ? '#52c41a' : '#ff4d4f', fontWeight: 500 }}>
{record.type === 'income' ? '+' : '-'}${(amount || 0).toFixed(2)}
</Text>
),
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
filters: [
{ text: '收入', value: 'income' },
{ text: '支出', value: 'expense' },
{ text: '退款', value: 'refund' },
{ text: '费用', value: 'fee' },
],
render: (type: string) => {
const typeConfig = {
income: { text: '收入', color: 'success' },
expense: { text: '支出', color: 'error' },
refund: { text: '退款', color: 'warning' },
fee: { text: '费用', color: 'processing' },
};
const config = typeConfig[type as keyof typeof typeConfig] || { text: type, color: 'default' };
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
filters: [
{ text: '销售', value: 'Sales' },
{ text: '供应商', value: 'Suppliers' },
{ text: '营销', value: 'Marketing' },
{ text: '物流', value: 'Shipping' },
{ text: '平台费用', value: 'Platform' },
{ text: '退款', value: 'Refund' },
],
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '日期',
dataIndex: 'date',
key: 'date',
sorter: (a: Transaction, b: Transaction) => moment(a.createdAt).unix() - moment(b.createdAt).unix(),
render: (date: string) => moment(date).format('YYYY-MM-DD HH:mm'),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
filters: [
{ text: '已完成', value: 'completed' },
{ text: '待处理', value: 'pending' },
{ text: '失败', value: 'failed' },
],
render: (status: string) => {
const statusConfig = {
completed: { text: '已完成', color: 'success', icon: <CheckCircleOutlined /> },
pending: { text: '待处理', color: 'warning', icon: <SyncOutlined spin /> },
failed: { text: '失败', color: 'error', icon: <CloseCircleOutlined /> },
};
const config = statusConfig[status as keyof typeof statusConfig];
return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>;
},
},
{
title: '操作',
key: 'action',
render: (_: any, record: Transaction) => (
<Space>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>
</Button>
</Space>
),
},
];
return (
<div className="transactions">
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Col>
<Title level={4}></Title>
<Text type="secondary"></Text>
</Col>
<Col>
<Space>
<Button icon={<ReloadOutlined />} onClick={handleRefresh}>
</Button>
<Button icon={<ExportOutlined />} onClick={handleExport}>
</Button>
</Space>
</Col>
</Row>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="总收入"
value={stats.income}
precision={2}
prefix={<ArrowUpOutlined />}
valueStyle={{ color: '#52c41a' }}
suffix="USD"
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="总支出"
value={stats.expense}
precision={2}
prefix={<ArrowDownOutlined />}
valueStyle={{ color: '#ff4d4f' }}
suffix="USD"
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="净利润"
value={stats.profit}
precision={2}
prefix={<DollarOutlined />}
valueStyle={{ color: stats.profit >= 0 ? '#52c41a' : '#ff4d4f' }}
suffix="USD"
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card size="small">
<Statistic
title="待处理"
value={stats.pending}
precision={2}
prefix={<SyncOutlined />}
valueStyle={{ color: '#faad14' }}
suffix="USD"
/>
</Card>
</Col>
</Row>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={16}>
<Card title="收支趋势最近7天" size="small">
<ResponsiveContainer width="100%" height={250}>
<LineChart
data={trendData}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="income" name="收入" stroke="#52c41a" activeDot={{ r: 8 }} />
<Line type="monotone" dataKey="expense" name="支出" stroke="#ff4d4f" />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
<Col span={8}>
<Card title="支出分类" size="small">
<ResponsiveContainer width="100%" height={250}>
<RechartsPie
data={categoryData}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
dataKey="value"
label={({ name, percent }) => `${name}: ${((percent || 0) * 100).toFixed(0)}%`}
>
{categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={['#1890ff', '#52c41a', '#faad14', '#ff4d4f', '#722ed1', '#eb2f96'][index % 6]} />
))}
</RechartsPie>
</ResponsiveContainer>
</Card>
</Col>
</Row>
{selectedRows.length > 0 && (
<Alert
message={`已选择 ${selectedRows.length}`}
action={
<Space>
<Button icon={<ExportOutlined />} onClick={handleBatchExport}>
</Button>
<Button onClick={() => setSelectedRows([])}>
</Button>
</Space>
}
style={{ marginBottom: 16 }}
/>
)}
<Card>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Search
placeholder="搜索交易ID或描述"
style={{ width: 300 }}
onSearch={(value) => setFilters({ ...filters, search: value })}
allowClear
/>
<Select
placeholder="类型"
style={{ width: 120 }}
value={filters.type}
onChange={(value) => setFilters({ ...filters, type: value })}
allowClear
>
<Option value="income"></Option>
<Option value="expense"></Option>
</Select>
<Select
placeholder="分类"
style={{ width: 120 }}
value={filters.category}
onChange={(value) => setFilters({ ...filters, category: value })}
allowClear
>
<Option value="Sales"></Option>
<Option value="Suppliers"></Option>
<Option value="Marketing"></Option>
<Option value="Shipping"></Option>
<Option value="Platform"></Option>
<Option value="Refund">退</Option>
</Select>
<Select
placeholder="状态"
style={{ width: 120 }}
value={filters.status}
onChange={(value) => setFilters({ ...filters, status: value })}
allowClear
>
<Option value="completed"></Option>
<Option value="pending"></Option>
<Option value="failed"></Option>
</Select>
<RangePicker
style={{ width: 300 }}
value={filters.dateRange}
onChange={(dates) => setFilters({ ...filters, dateRange: dates })}
/>
<Button
icon={<FilterOutlined />}
onClick={() => fetchTransactions(filters)}
>
</Button>
</div>
<Table
columns={columns}
dataSource={transactions}
loading={loading}
rowKey="id"
rowSelection={{
selectedRowKeys: selectedRows.map(r => r.id),
onChange: (selectedRowKeys: React.Key[], selectedRows: Transaction[]) => {
setSelectedRows(selectedRows);
},
}}
pagination={{
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
pageSizeOptions: ['10', '20', '50', '100'],
}}
scroll={{ x: 1200 }}
/>
</Card>
<Drawer
title="交易详情"
placement="right"
onClose={() => setDetailDrawerVisible(false)}
open={detailDrawerVisible}
width={600}
>
{currentTransaction && (
<Descriptions column={1} bordered>
<Descriptions.Item label="交易ID">{currentTransaction.id}</Descriptions.Item>
<Descriptions.Item label="金额">
<Text style={{ color: currentTransaction.type === 'income' ? '#52c41a' : '#ff4d4f', fontSize: 18, fontWeight: 500 }}>
{currentTransaction.type === 'income' ? '+' : '-'}${currentTransaction.amount.toFixed(2)}
</Text>
</Descriptions.Item>
<Descriptions.Item label="类型">
<Tag color={currentTransaction.type === 'income' ? 'success' : 'error'}>
{currentTransaction.type === 'income' ? '收入' : '支出'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="分类">{currentTransaction.category}</Descriptions.Item>
<Descriptions.Item label="描述">{currentTransaction.description}</Descriptions.Item>
<Descriptions.Item label="日期">{moment(currentTransaction.createdAt).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={currentTransaction.status === 'completed' ? 'success' : currentTransaction.status === 'pending' ? 'warning' : 'error'}>
{currentTransaction.status === 'completed' ? '已完成' : currentTransaction.status === 'pending' ? '待处理' : '失败'}
</Tag>
</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
};
export default Transactions;