118 lines
9.0 KiB
TypeScript
118 lines
9.0 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { Download, Search } from 'lucide-react';
|
||
import { adminApi, type AdminChannel, type DailyProfitReport, type EnterpriseApplication, type ProfitReportSummary, type TenantOption } from '@/api/adminApi';
|
||
import { Breadcrumb, Button, DateRangeInput, MoneyText, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||
import { formatCents } from '@/utils/currency';
|
||
import { formatDateTime } from '@/utils/dateTime';
|
||
|
||
const pageSize = 20;
|
||
|
||
export function AdminProfitReportsPage() {
|
||
const [rows, setRows] = useState<DailyProfitReport[]>([]);
|
||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultDateRange());
|
||
const [dimensionType, setDimensionType] = useState<'application' | 'channel'>('application');
|
||
const [tenantId, setTenantId] = useState('');
|
||
const [applicationId, setApplicationId] = useState('');
|
||
const [channelId, setChannelId] = useState('');
|
||
const [page, setPage] = useState(1);
|
||
const [total, setTotal] = useState(0);
|
||
const [summary, setSummary] = useState<ProfitReportSummary>(emptyProfitSummary);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [exporting, setExporting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listChannels()])
|
||
.then(([nextTenants, nextApplications, nextChannels]) => { setTenants(nextTenants); setApplications(nextApplications); setChannels(nextChannels); })
|
||
.catch(() => { setTenants([]); setApplications([]); setChannels([]); });
|
||
}, []);
|
||
useEffect(() => { void loadData(); }, [page, dateRange.start, dateRange.end, dimensionType, tenantId, applicationId, channelId]);
|
||
|
||
async function loadData() {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const response = await adminApi.listProfitReports({ dateFrom: dateRange.start, dateTo: dateRange.end, dimensionType, tenantId: tenantId || undefined, applicationId: applicationId || undefined, channelId: channelId || undefined, page, pageSize });
|
||
setRows(response.items);
|
||
setTotal(response.total);
|
||
setSummary(response.summary);
|
||
} catch (failure) {
|
||
setRows([]);
|
||
setTotal(0);
|
||
setSummary(emptyProfitSummary);
|
||
setError(failure instanceof Error ? failure.message : '利润报表加载失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function exportData() {
|
||
setExporting(true); setError('');
|
||
try { downloadBlob(await adminApi.exportProfitReports({ dateFrom: dateRange.start, dateTo: dateRange.end, dimensionType, tenantId: tenantId || undefined, applicationId: applicationId || undefined, channelId: channelId || undefined }), '利润报表.csv'); }
|
||
catch (failure) { setError(failure instanceof Error ? failure.message : '利润报表导出失败'); }
|
||
finally { setExporting(false); }
|
||
}
|
||
|
||
const availableApplications = useMemo(() => applications.filter((application) => !tenantId || application.tenantId === tenantId), [applications, tenantId]);
|
||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||
|
||
return (
|
||
<section className="page-stack">
|
||
<div className="page-heading">
|
||
<div><Breadcrumb items={['报表对账', '利润报表']} /><h1>利润报表</h1></div>
|
||
<div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">T+1 生成 · 每日重算 T-4~T-1</Tag></div>
|
||
</div>
|
||
|
||
<div className="surface admin-report-filter-grid admin-report-filter-grid--profit">
|
||
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
|
||
<Select label="统计维度" onChange={(event) => { setDimensionType(event.target.value as 'application' | 'channel'); setTenantId(''); setApplicationId(''); setChannelId(''); setPage(1); }} options={[{ label: '按企业应用', value: 'application' }, { label: '按通道', value: 'channel' }]} value={dimensionType} />
|
||
{dimensionType === 'application' ? <Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} /> : <Select label="短信通道" onChange={(event) => { setChannelId(event.target.value); setPage(1); }} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} />}
|
||
{dimensionType === 'application' ? <Select label="企业应用" onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} /> : <div />}
|
||
<Button icon={<Search size={16} />} onClick={() => void loadData()}>查询</Button>
|
||
</div>
|
||
|
||
<div className="surface admin-report-summary">
|
||
<div className="admin-report-summary__heading"><strong>筛选结果汇总</strong><span>统计全部匹配数据,不受当前分页影响</span></div>
|
||
<div className="admin-report-summary__grid">{[
|
||
['提交合计', summary.submittedUnits.toLocaleString('zh-CN')], ['发送合计', summary.sentUnits.toLocaleString('zh-CN')], ['未知合计', summary.unknownUnits.toLocaleString('zh-CN')], ['成功合计', summary.successUnits.toLocaleString('zh-CN')], ['失败合计', summary.failedUnits.toLocaleString('zh-CN')],
|
||
['收入合计', `¥${formatCents(summary.revenueCents)}`], ['成本合计', `¥${formatCents(summary.costCents)}`], ['利润合计', `¥${formatCents(summary.profitCents)}`], ['综合利润率', `${(summary.profitRateBps / 100).toFixed(2)}%`],
|
||
].map(([label, value]) => <div key={label}><span>{label}</span><strong>{label.includes('合计') && ['收入合计', '成本合计', '利润合计'].includes(label) ? <MoneyText>{value}</MoneyText> : value}</strong></div>)}</div>
|
||
</div>
|
||
|
||
<div className="surface">
|
||
<div className="ui-table-wrap">
|
||
<table className="ui-table">
|
||
<thead><tr><th>发送日期</th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th>提交</th><th>发送</th><th>未知</th><th>成功</th><th>失败</th><th>收入</th><th>成本</th><th>利润</th><th>利润率</th><th>生成时间</th></tr></thead>
|
||
<tbody>
|
||
{error ? <tr><td className="ui-table__empty" colSpan={12}>{error}</td></tr>
|
||
: loading ? <tr><td className="ui-table__empty" colSpan={12}>正在加载真实利润数据...</td></tr>
|
||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={12}>暂无已生成的利润报表</td></tr>
|
||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td><MoneyText>¥{formatCents(row.revenueCents)}</MoneyText></td><td><MoneyText>¥{formatCents(row.costCents)}</MoneyText></td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}><MoneyText>¥{formatCents(row.profitCents)}</MoneyText></td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Pagination page={page} totalPages={totalPages} total={total} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} previousDisabled={page <= 1} nextDisabled={page >= totalPages} />
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const emptyProfitSummary: ProfitReportSummary = { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 };
|
||
|
||
function defaultDateRange(): DateRangeValue {
|
||
const end = new Date();
|
||
end.setDate(end.getDate() - 1);
|
||
const start = new Date(end);
|
||
start.setDate(start.getDate() - 29);
|
||
return { start: localDate(start), end: localDate(end) };
|
||
}
|
||
|
||
function localDate(value: Date) {
|
||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
function downloadBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); }
|