Files
lislgosms/src/apps/admin/AdminProfitReportsPage.tsx
T

118 lines
9.0 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 { 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-4T-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); }