405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
import { useState } from 'react';
|
||
import {
|
||
Card,
|
||
Table,
|
||
Tag,
|
||
Button,
|
||
Space,
|
||
Select,
|
||
Modal,
|
||
Descriptions,
|
||
Typography,
|
||
Popconfirm,
|
||
Form,
|
||
Input,
|
||
Spin,
|
||
} from 'antd';
|
||
import {
|
||
CheckCircleOutlined,
|
||
LockOutlined,
|
||
PlusOutlined,
|
||
} from '@ant-design/icons';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
useFinancialReports,
|
||
useGenerateReport,
|
||
useConfirmReport,
|
||
useLockReport,
|
||
type FinancialReportDto,
|
||
} from '../../application';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
DRAFT: 'default',
|
||
CONFIRMED: 'success',
|
||
LOCKED: 'purple',
|
||
};
|
||
|
||
const STATUS_LABELS: Record<string, string> = {
|
||
DRAFT: '草稿',
|
||
CONFIRMED: '已确认',
|
||
LOCKED: '已锁定',
|
||
};
|
||
|
||
export function ReportsPage() {
|
||
const currentYear = dayjs().year();
|
||
const [selectedYear, setSelectedYear] = useState<number | undefined>(currentYear);
|
||
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||
const [selectedReport, setSelectedReport] = useState<FinancialReportDto | null>(null);
|
||
const [generateModalOpen, setGenerateModalOpen] = useState(false);
|
||
const [generateForm] = Form.useForm();
|
||
|
||
// Queries
|
||
const { data: reports, isLoading } = useFinancialReports(selectedYear, selectedStatus);
|
||
|
||
// Mutations
|
||
const generateMutation = useGenerateReport();
|
||
const confirmMutation = useConfirmReport();
|
||
const lockMutation = useLockReport();
|
||
|
||
const showDetail = (report: FinancialReportDto) => {
|
||
setSelectedReport(report);
|
||
setDetailModalOpen(true);
|
||
};
|
||
|
||
const handleGenerate = async () => {
|
||
try {
|
||
const values = await generateForm.validateFields();
|
||
generateMutation.mutate(values.month);
|
||
setGenerateModalOpen(false);
|
||
generateForm.resetFields();
|
||
} catch {
|
||
// Form validation failed
|
||
}
|
||
};
|
||
|
||
const columns: ColumnsType<FinancialReportDto> = [
|
||
{
|
||
title: '月份',
|
||
dataIndex: 'reportMonth',
|
||
key: 'reportMonth',
|
||
width: 100,
|
||
},
|
||
{
|
||
title: '总收入',
|
||
dataIndex: 'totalRevenue',
|
||
key: 'totalRevenue',
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '退款',
|
||
dataIndex: 'totalRefunds',
|
||
key: 'totalRefunds',
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '净收入',
|
||
dataIndex: 'netRevenue',
|
||
key: 'netRevenue',
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '总成本',
|
||
dataIndex: 'totalCosts',
|
||
key: 'totalCosts',
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '毛利',
|
||
dataIndex: 'grossProfit',
|
||
key: 'grossProfit',
|
||
render: (v) => (
|
||
<span style={{ color: Number(v) >= 0 ? '#52c41a' : '#f5222d' }}>
|
||
¥{Number(v).toFixed(2)}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
title: '毛利率',
|
||
dataIndex: 'grossMargin',
|
||
key: 'grossMargin',
|
||
render: (v) => `${(Number(v) * 100).toFixed(1)}%`,
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
key: 'status',
|
||
render: (status) => (
|
||
<Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status] || status}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 200,
|
||
render: (_, record) => (
|
||
<Space>
|
||
<Button size="small" onClick={() => showDetail(record)}>
|
||
详情
|
||
</Button>
|
||
{record.status === 'DRAFT' && (
|
||
<Popconfirm
|
||
title="确认报表"
|
||
description="确认后报表数据将不可修改,是否继续?"
|
||
onConfirm={() => confirmMutation.mutate(record.reportMonth)}
|
||
okText="确认"
|
||
cancelText="取消"
|
||
>
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
icon={<CheckCircleOutlined />}
|
||
loading={confirmMutation.isPending}
|
||
>
|
||
确认
|
||
</Button>
|
||
</Popconfirm>
|
||
)}
|
||
{record.status === 'CONFIRMED' && (
|
||
<Popconfirm
|
||
title="锁定报表"
|
||
description="锁定后报表将无法再修改,是否继续?"
|
||
onConfirm={() => lockMutation.mutate(record.reportMonth)}
|
||
okText="锁定"
|
||
cancelText="取消"
|
||
>
|
||
<Button
|
||
size="small"
|
||
icon={<LockOutlined />}
|
||
loading={lockMutation.isPending}
|
||
>
|
||
锁定
|
||
</Button>
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
// Generate year options (last 3 years)
|
||
const yearOptions = Array.from({ length: 3 }, (_, i) => ({
|
||
value: currentYear - i,
|
||
label: `${currentYear - i}年`,
|
||
}));
|
||
|
||
return (
|
||
<div className="p-6">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<Title level={4} className="mb-0">财务报表</Title>
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => setGenerateModalOpen(true)}
|
||
>
|
||
生成报表
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<Card className="mb-4">
|
||
<Space>
|
||
<span>年份:</span>
|
||
<Select
|
||
value={selectedYear}
|
||
onChange={setSelectedYear}
|
||
options={[{ value: undefined, label: '全部' }, ...yearOptions]}
|
||
style={{ width: 120 }}
|
||
allowClear
|
||
placeholder="全部"
|
||
/>
|
||
<span className="ml-4">状态:</span>
|
||
<Select
|
||
value={selectedStatus}
|
||
onChange={setSelectedStatus}
|
||
options={[
|
||
{ value: undefined, label: '全部' },
|
||
{ value: 'DRAFT', label: '草稿' },
|
||
{ value: 'CONFIRMED', label: '已确认' },
|
||
{ value: 'LOCKED', label: '已锁定' },
|
||
]}
|
||
style={{ width: 120 }}
|
||
allowClear
|
||
placeholder="全部"
|
||
/>
|
||
</Space>
|
||
</Card>
|
||
|
||
{/* Reports Table */}
|
||
<Card>
|
||
<Spin spinning={isLoading}>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={reports || []}
|
||
rowKey="id"
|
||
pagination={{
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
}}
|
||
/>
|
||
</Spin>
|
||
</Card>
|
||
|
||
{/* Detail Modal */}
|
||
<Modal
|
||
title={`财务报表详情 - ${selectedReport?.reportMonth}`}
|
||
open={detailModalOpen}
|
||
onCancel={() => setDetailModalOpen(false)}
|
||
footer={[
|
||
<Button key="close" onClick={() => setDetailModalOpen(false)}>
|
||
关闭
|
||
</Button>,
|
||
]}
|
||
width={800}
|
||
>
|
||
{selectedReport && (
|
||
<div>
|
||
<Descriptions bordered column={2} size="small">
|
||
<Descriptions.Item label="报表月份">
|
||
{selectedReport.reportMonth}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={STATUS_COLORS[selectedReport.status]}>
|
||
{STATUS_LABELS[selectedReport.status]}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="总收入">
|
||
¥{Number(selectedReport.totalRevenue).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="评估收入">
|
||
¥{Number(selectedReport.assessmentRevenue).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="咨询收入">
|
||
¥{Number(selectedReport.consultationRevenue).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="其他收入">
|
||
¥{Number(selectedReport.otherRevenue).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="退款总额">
|
||
¥{Number(selectedReport.totalRefunds).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="净收入">
|
||
¥{Number(selectedReport.netRevenue).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="API成本">
|
||
¥{Number(selectedReport.apiCost).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="支付手续费">
|
||
¥{Number(selectedReport.paymentFees).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="其他成本">
|
||
¥{Number(selectedReport.otherCosts).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="总成本">
|
||
¥{Number(selectedReport.totalCosts).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="毛利">
|
||
<span style={{ color: Number(selectedReport.grossProfit) >= 0 ? '#52c41a' : '#f5222d' }}>
|
||
¥{Number(selectedReport.grossProfit).toFixed(2)}
|
||
</span>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="毛利率">
|
||
{(Number(selectedReport.grossMargin) * 100).toFixed(1)}%
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="总订单数">
|
||
{selectedReport.totalOrders}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="成功订单数">
|
||
{selectedReport.successfulOrders}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="平均订单金额">
|
||
¥{Number(selectedReport.avgOrderAmount).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="确认人">
|
||
{selectedReport.confirmedBy || '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="确认时间">
|
||
{selectedReport.confirmedAt
|
||
? dayjs(selectedReport.confirmedAt).format('YYYY-MM-DD HH:mm:ss')
|
||
: '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="创建时间">
|
||
{dayjs(selectedReport.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
{/* Revenue by Category */}
|
||
<div className="mt-4">
|
||
<Text strong>按类别收入</Text>
|
||
<div className="mt-2 p-3 bg-gray-50 rounded">
|
||
{Object.entries(selectedReport.revenueByCategory || {}).length > 0 ? (
|
||
Object.entries(selectedReport.revenueByCategory).map(([key, value]) => (
|
||
<Tag key={key} className="mb-1">
|
||
{key}: ¥{Number(value).toFixed(2)}
|
||
</Tag>
|
||
))
|
||
) : (
|
||
<Text type="secondary">暂无数据</Text>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Revenue by Channel */}
|
||
<div className="mt-4">
|
||
<Text strong>按渠道收入</Text>
|
||
<div className="mt-2 p-3 bg-gray-50 rounded">
|
||
{Object.entries(selectedReport.revenueByChannel || {}).length > 0 ? (
|
||
Object.entries(selectedReport.revenueByChannel).map(([key, value]) => (
|
||
<Tag key={key} className="mb-1">
|
||
{key}: ¥{Number(value).toFixed(2)}
|
||
</Tag>
|
||
))
|
||
) : (
|
||
<Text type="secondary">暂无数据</Text>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Notes */}
|
||
{selectedReport.notes && (
|
||
<div className="mt-4">
|
||
<Text strong>备注</Text>
|
||
<div className="mt-2 p-3 bg-gray-50 rounded">
|
||
<Text>{selectedReport.notes}</Text>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* Generate Report Modal */}
|
||
<Modal
|
||
title="生成财务报表"
|
||
open={generateModalOpen}
|
||
onOk={handleGenerate}
|
||
onCancel={() => {
|
||
setGenerateModalOpen(false);
|
||
generateForm.resetFields();
|
||
}}
|
||
confirmLoading={generateMutation.isPending}
|
||
>
|
||
<Form form={generateForm} layout="vertical">
|
||
<Form.Item
|
||
name="month"
|
||
label="报表月份"
|
||
rules={[
|
||
{ required: true, message: '请输入报表月份' },
|
||
{ pattern: /^\d{4}-\d{2}$/, message: '格式应为 YYYY-MM' },
|
||
]}
|
||
>
|
||
<Input placeholder="例如: 2024-01" />
|
||
</Form.Item>
|
||
<p className="text-gray-500 text-sm">
|
||
生成指定月份的财务报表。如果报表已存在且为草稿状态,将会更新数据。
|
||
</p>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|