R92 A3-A4 优化发票上传状态与批量上传
这个提交包含在:
@@ -19,6 +19,7 @@ import {
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
type InvoicePoolStatus,
|
||||
type UploadInvoiceResponse,
|
||||
} from "../api/invoicePool";
|
||||
import { fetchReimbursements } from "../api/reimbursements";
|
||||
import { fetchTravelRequestDetail, fetchTravelRequests } from "../api/travelRequests";
|
||||
@@ -37,6 +38,17 @@ import { InvoiceDetailPanel } from "./invoice-pool/InvoiceDetailPanel";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
type UploadBatchProgress = {
|
||||
completed: number;
|
||||
total: number;
|
||||
currentFileName: string;
|
||||
};
|
||||
|
||||
type UploadBatchResult = {
|
||||
successes: Array<{ file: File; data: UploadInvoiceResponse }>;
|
||||
failures: Array<{ file: File; message: string }>;
|
||||
};
|
||||
|
||||
export function InvoicePoolPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions, me } = useAuth();
|
||||
@@ -51,7 +63,8 @@ export function InvoicePoolPage() {
|
||||
const [status, setStatus] = useState<InvoicePoolStatus | "">("");
|
||||
const [ownerScope, setOwnerScope] = useState<"own" | "all">("own");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState<UploadBatchProgress | null>(null);
|
||||
const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState("");
|
||||
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
|
||||
const [bindMode, setBindMode] = useState<InvoiceBindMode>("reimbursement");
|
||||
@@ -85,6 +98,10 @@ export function InvoicePoolPage() {
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
refetchInterval: (query) => {
|
||||
const items = query.state.data?.items ?? [];
|
||||
return items.some((item) => item.status === "pending_ocr") ? 3_000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const reimbursementQuery = useQuery({
|
||||
@@ -192,10 +209,14 @@ export function InvoicePoolPage() {
|
||||
|
||||
// --- Helper for error handling ---
|
||||
|
||||
const handleError = (err: unknown, fallback: string) => {
|
||||
const msg = isApiError(err)
|
||||
const toUserError = (err: unknown, fallback: string) => {
|
||||
return isApiError(err)
|
||||
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
|
||||
: err instanceof Error ? err.message : fallback;
|
||||
};
|
||||
|
||||
const handleError = (err: unknown, fallback: string) => {
|
||||
const msg = toUserError(err, fallback);
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
};
|
||||
@@ -204,18 +225,52 @@ export function InvoicePoolPage() {
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedFile) {
|
||||
if (!selectedFiles.length) {
|
||||
throw new Error("请选择文件");
|
||||
}
|
||||
return uploadInvoice(selectedFile, "vat", uploadOwnerEmployeeId || undefined);
|
||||
|
||||
const files = [...selectedFiles];
|
||||
const result: UploadBatchResult = { successes: [], failures: [] };
|
||||
setUploadProgress({ completed: 0, total: files.length, currentFileName: files[0]?.name ?? "" });
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
setUploadProgress({ completed: index, total: files.length, currentFileName: file.name });
|
||||
try {
|
||||
const data = await uploadInvoice(file, "vat", uploadOwnerEmployeeId || undefined);
|
||||
result.successes.push({ file, data });
|
||||
} catch (err) {
|
||||
result.failures.push({ file, message: toUserError(err, "上传失败,请稍后重试") });
|
||||
}
|
||||
setUploadProgress({ completed: index + 1, total: files.length, currentFileName: file.name });
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
setDetailInvoiceId(data.invoicePoolId);
|
||||
setSelectedFile(null);
|
||||
const lastSuccess = result.successes.at(-1);
|
||||
if (lastSuccess) {
|
||||
setDetailInvoiceId(lastSuccess.data.invoicePoolId);
|
||||
}
|
||||
setSelectedFiles(result.failures.map((item) => item.file));
|
||||
setUploadProgress(null);
|
||||
|
||||
if (result.failures.length) {
|
||||
const message = result.failures.map((item) => `${item.file.name}: ${item.message}`).join(";");
|
||||
setError(message);
|
||||
if (result.successes.length) {
|
||||
toast.error(`已上传 ${result.successes.length} 个,${result.failures.length} 个失败`);
|
||||
} else {
|
||||
toast.error("上传失败,请检查文件或稍后重试");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
toast.success(data.status === "pending_ocr" ? "上传成功,OCR 识别中" : "上传成功,可直接处理");
|
||||
const hasPending = result.successes.some((item) => item.data.status === "pending_ocr");
|
||||
toast.success(hasPending ? `已上传 ${result.successes.length} 个,OCR 识别中` : `已上传 ${result.successes.length} 个,可直接处理`);
|
||||
},
|
||||
onSettled: () => setUploadProgress(null),
|
||||
onError: (err) => handleError(err, "上传失败,请稍后重试"),
|
||||
});
|
||||
|
||||
@@ -422,9 +477,15 @@ export function InvoicePoolPage() {
|
||||
) : null}
|
||||
|
||||
<InvoiceUploadCard
|
||||
uploadMutation={uploadMutation}
|
||||
selectedFile={selectedFile}
|
||||
setSelectedFile={setSelectedFile}
|
||||
selectedFiles={selectedFiles}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
isUploading={uploadMutation.isPending}
|
||||
uploadProgressLabel={
|
||||
uploadProgress
|
||||
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: "上传中..."
|
||||
}
|
||||
onUpload={() => uploadMutation.mutate()}
|
||||
showProxyOwnerSelector={showProxyOwnerSelector}
|
||||
proxyDelegationsQuery={proxyDelegationsQuery}
|
||||
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
|
||||
|
||||
在新工单中引用
屏蔽一个用户