394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Check, Eye, Search, X } from 'lucide-react';
|
||
import { adminApi, type ReportImportReviewBatch, type ReportImportReviewItem } from '@/api/adminApi';
|
||
import {
|
||
Button,
|
||
DateRangeInput,
|
||
Input,
|
||
Modal,
|
||
Pagination,
|
||
Select,
|
||
Table,
|
||
Tag,
|
||
Textarea,
|
||
type DateRangeValue,
|
||
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.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,
|
||
initialBatchId,
|
||
}: {
|
||
reportType: 'signature' | 'drainage';
|
||
initialBatchId?: string;
|
||
}) {
|
||
const initialBatchHandled = useRef(false);
|
||
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 [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||
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,
|
||
startAt: submittedDateRange.start,
|
||
endAt: submittedDateRange.end,
|
||
page: targetPage,
|
||
pageSize,
|
||
})
|
||
.then((result) => {
|
||
setData(result);
|
||
setError('');
|
||
if (!initialBatchHandled.current && initialBatchId) {
|
||
setDetail(result.items.find((item) => item.id === initialBatchId));
|
||
initialBatchHandled.current = true;
|
||
} else if (detail) setDetail(result.items.find((item) => item.id === detail.id));
|
||
})
|
||
.catch((failure: Error) => setError(failure.message || '导入审核批次加载失败'));
|
||
}
|
||
|
||
useEffect(() => {
|
||
load(page);
|
||
}, [page, pageSize, reportType, status, submittedDateRange.end, submittedDateRange.start]);
|
||
|
||
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 });
|
||
window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
|
||
setSelected(new Set());
|
||
setReason('');
|
||
setRejectOpen(false);
|
||
const refreshed = await adminApi.listReportImportReviewBatches({
|
||
reportType,
|
||
status: status === 'all' ? undefined : status,
|
||
keyword: keyword.trim() || undefined,
|
||
startAt: submittedDateRange.start,
|
||
endAt: submittedDateRange.end,
|
||
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="ui-filter-row">
|
||
<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}
|
||
/>
|
||
<DateRangeInput
|
||
label="提交时间"
|
||
onChange={(value) => {
|
||
setSubmittedDateRange(value);
|
||
setPage(1);
|
||
}}
|
||
value={submittedDateRange}
|
||
/>
|
||
<div className="audit-filter-actions ui-filter-actions">
|
||
<Button
|
||
icon={<Search size={16} />}
|
||
onClick={() => {
|
||
setPage(1);
|
||
load(1);
|
||
}}
|
||
>
|
||
查询
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
setKeyword('');
|
||
setStatus('all');
|
||
setSubmittedDateRange({});
|
||
setPage(1);
|
||
}}
|
||
variant="ghost"
|
||
>
|
||
重置
|
||
</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>
|
||
);
|
||
}
|