feat: add reconciliation and quality reporting

This commit is contained in:
hectorzhao
2026-07-15 14:22:50 +08:00
parent 16311546af
commit 8c3336600e
32 changed files with 1730 additions and 200 deletions
@@ -0,0 +1,100 @@
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { adminApi, type DailyReconciliationReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const pageSize = 20;
export function AdminReconciliationReportsPage() {
const [rows, setRows] = useState<DailyReconciliationReport[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultDateRange());
const [tenantId, setTenantId] = useState('');
const [applicationId, setApplicationId] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications()])
.then(([nextTenants, nextApplications]) => { setTenants(nextTenants); setApplications(nextApplications); })
.catch(() => { setTenants([]); setApplications([]); });
}, []);
useEffect(() => { void loadData(); }, [page, dateRange.start, dateRange.end, tenantId, applicationId]);
async function loadData() {
setLoading(true);
setError('');
try {
const response = await adminApi.listReconciliationReports({
dateFrom: dateRange.start,
dateTo: dateRange.end,
tenantId: tenantId || undefined,
applicationId: applicationId || undefined,
page,
pageSize,
});
setRows(response.items);
setTotal(response.total);
} catch (failure) {
setRows([]);
setTotal(0);
setError(failure instanceof Error ? failure.message : '对账单加载失败');
} finally {
setLoading(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>
<Tag tone="info">T+1 · T-4T-1</Tag>
</div>
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(280px, 1.4fr) minmax(200px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<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) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />
<Button icon={<Search size={16} />} onClick={() => void loadData()}></Button>
</div>
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={6}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={6}>...</td></tr>
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={6}></td></tr>
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</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>
);
}
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')}`;
}