feat: complete reporting and filing workflows
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Eye, Search, X } from 'lucide-react';
|
||||
import { adminApi, type ReportImportReviewBatch, type ReportImportReviewItem } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending_review: '待审核',
|
||||
partially_reviewed: '部分已审核',
|
||||
partially_approved: '部分通过',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
failed: '导入失败',
|
||||
};
|
||||
|
||||
const itemStatusLabels: Record<string, string> = {
|
||||
pending_review: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
invalid: '数据异常',
|
||||
};
|
||||
|
||||
function itemName(item: ReportImportReviewItem) {
|
||||
if (item.reportType === 'signature') return String(item.payload.name ?? '-');
|
||||
return String(item.payload.siteName ?? item.payload.url ?? '-');
|
||||
}
|
||||
|
||||
function tone(status: string): 'neutral' | 'info' | 'success' | 'warning' | 'danger' {
|
||||
if (status === 'approved') return 'success';
|
||||
if (['rejected', 'failed', 'invalid'].includes(status)) return 'danger';
|
||||
if (['partially_reviewed', 'partially_approved'].includes(status)) return 'warning';
|
||||
return status === 'pending_review' ? 'info' : 'neutral';
|
||||
}
|
||||
|
||||
export function ReportImportAuditPanel({ reportType }: { reportType: 'signature' | 'drainage' }) {
|
||||
const [data, setData] = useState<{ items: ReportImportReviewBatch[]; total: number; page: number; pageSize: number }>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [detail, setDetail] = useState<ReportImportReviewBatch>();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function load(targetPage = page) {
|
||||
adminApi.listReportImportReviewBatches({
|
||||
reportType,
|
||||
status: status === 'all' ? undefined : status,
|
||||
keyword: keyword.trim() || undefined,
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
}).then((result) => {
|
||||
setData(result);
|
||||
setError('');
|
||||
if (detail) setDetail(result.items.find((item) => item.id === detail.id));
|
||||
}).catch((failure: Error) => setError(failure.message || '导入审核批次加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(page);
|
||||
}, [page, pageSize, reportType, status]);
|
||||
|
||||
const pendingItems = detail?.items.filter((item) => item.status === 'pending_review') ?? [];
|
||||
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((item) => selected.has(item.id));
|
||||
|
||||
async function review(decision: 'approve' | 'reject') {
|
||||
if (!detail) return;
|
||||
const itemIds = selected.size ? [...selected] : pendingItems.map((item) => item.id);
|
||||
if (!itemIds.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await adminApi.reviewReportImportItems(detail.id, { decision, itemIds, reason: reason.trim() || undefined });
|
||||
setSelected(new Set());
|
||||
setReason('');
|
||||
setRejectOpen(false);
|
||||
const refreshed = await adminApi.listReportImportReviewBatches({
|
||||
reportType,
|
||||
status: status === 'all' ? undefined : status,
|
||||
keyword: keyword.trim() || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
setData(refreshed);
|
||||
setDetail(refreshed.items.find((item) => item.id === detail.id));
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '导入批次审核失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ReportImportReviewBatch>>>(() => [
|
||||
{ key: 'file', title: '导入批次/文件', render: (record) => <div><strong>{record.id}</strong><div className="muted">{record.fileName}</div></div> },
|
||||
{ key: 'tenant', title: '企业/应用', render: (record) => <div><strong>{record.tenant?.name ?? record.tenantId}</strong><div className="muted">{record.application?.name ?? '未指定应用'}</div></div> },
|
||||
{ key: 'summary', title: '明细统计', render: (record) => {
|
||||
const approved = record.items.filter((item) => item.status === 'approved').length;
|
||||
const pending = record.items.filter((item) => item.status === 'pending_review').length;
|
||||
const abnormal = record.items.filter((item) => item.status === 'invalid').length;
|
||||
return `共${record.items.length} · 待审${pending} · 通过${approved} · 异常${abnormal}`;
|
||||
} },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={tone(record.status)}>{statusLabels[record.status] ?? record.status}</Tag> },
|
||||
{ key: 'createdAt', title: '导入时间', render: (record) => formatDateTime(record.createdAt) },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <Button icon={<Eye size={15} />} onClick={() => {
|
||||
setDetail(record);
|
||||
setSelected(new Set());
|
||||
}} size="sm" variant="ghost">详情</Button> },
|
||||
], []);
|
||||
|
||||
const itemColumns: Array<TableColumn<ReportImportReviewItem>> = [
|
||||
{ key: 'select', title: '', width: '48px', render: (item) => <input aria-label={`选择第${item.rowNumber}行`} checked={selected.has(item.id)} disabled={item.status !== 'pending_review'} onChange={() => setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
})} type="checkbox" /> },
|
||||
{ key: 'row', title: '文件行', width: '80px', render: (item) => `第${item.rowNumber}行` },
|
||||
{ key: 'name', title: reportType === 'signature' ? '签名' : '引流url或号码', render: (item) => <strong>{itemName(item)}</strong> },
|
||||
{ key: 'operation', title: '变更类型', width: '100px', render: (item) => item.operation === 'create' ? '新增' : item.operation === 'update' ? '修改' : '无效' },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (item) => <Tag tone={tone(item.status)}>{itemStatusLabels[item.status] ?? item.status}</Tag> },
|
||||
{ key: 'reason', title: '说明', render: (item) => item.errorMessage || item.reviewReason || '-' },
|
||||
];
|
||||
|
||||
return <div className="page-stack">
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--template">
|
||||
<Input label="批次号/文件名" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索导入批次或文件" value={keyword} />
|
||||
<Select label="批次状态" onChange={(event) => { setStatus(event.target.value); setPage(1); }} options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待审核', value: 'pending_review' },
|
||||
{ label: '部分已审核', value: 'partially_reviewed' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '部分通过', value: 'partially_approved' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
]} value={status} />
|
||||
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPage(1); load(1); }}>查询</Button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface"><Table columns={columns} data={data.items} emptyText="暂无导入审核批次" pagination={false} rowKey="id" /></div>
|
||||
<Pagination
|
||||
nextDisabled={page * pageSize >= data.total}
|
||||
onNext={() => setPage((current) => current + 1)}
|
||||
onPageChange={setPage}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
page={page}
|
||||
previousDisabled={page <= 1}
|
||||
total={data.total}
|
||||
totalPages={Math.max(1, Math.ceil(data.total / pageSize))}
|
||||
/>
|
||||
{detail ? <Modal footer={<><Button onClick={() => setDetail(undefined)} variant="ghost">关闭</Button><Button disabled={busy || pendingItems.length === 0} icon={<X size={15} />} onClick={() => setRejectOpen(true)} variant="danger">{selected.size ? `驳回所选(${selected.size})` : `驳回全部可审核项(${pendingItems.length})`}</Button><Button disabled={busy || pendingItems.length === 0} icon={<Check size={15} />} onClick={() => void review('approve')} variant="success">{selected.size ? `通过所选(${selected.size})` : `通过全部可审核项(${pendingItems.length})`}</Button></>} onClose={() => setDetail(undefined)} open size="xl" title={`${reportType === 'signature' ? '签名' : '引流信息'}导入批次详情`}>
|
||||
<div className="page-stack">
|
||||
<div className="detail-grid">
|
||||
<div><span>导入文件</span><strong>{detail.fileName}</strong></div>
|
||||
<div><span>企业</span><strong>{detail.tenant?.name ?? detail.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{detail.application?.name ?? '未指定应用'}</strong></div>
|
||||
<div><span>导入时间</span><strong>{formatDateTime(detail.createdAt)}</strong></div>
|
||||
</div>
|
||||
<label className="table-actions"><input checked={allPendingSelected} onChange={() => setSelected(allPendingSelected ? new Set() : new Set(pendingItems.map((item) => item.id)))} type="checkbox" />选择全部待审核项</label>
|
||||
<Table columns={itemColumns} data={detail.items} emptyText="暂无导入明细" pagination={false} rowKey="id" />
|
||||
</div>
|
||||
</Modal> : null}
|
||||
{rejectOpen ? <Modal footer={<><Button onClick={() => setRejectOpen(false)} variant="ghost">取消</Button><Button disabled={busy} onClick={() => void review('reject')} variant="danger">确认驳回</Button></>} onClose={() => setRejectOpen(false)} open title="驳回导入资料">
|
||||
<Textarea label="驳回原因(选填)" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} />
|
||||
</Modal> : null}
|
||||
</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user