iconsulting/packages/admin-client/src/features/analytics/presentation/pages/ReportsPage.tsx

405 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}