R111/A7-A10 统一报销双端发票附件体验
这个提交包含在:
@@ -6,18 +6,22 @@ import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||
import {
|
||||
bindInvoice,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoiceStatuses,
|
||||
unbindInvoice,
|
||||
uploadInvoice,
|
||||
type BindableInvoiceSummary,
|
||||
} from "../api/invoicePool";
|
||||
import {
|
||||
createReimbursementItem,
|
||||
deleteReimbursementAttachment,
|
||||
deleteReimbursement,
|
||||
deleteReimbursementItem,
|
||||
downloadReimbursementAttachment,
|
||||
fetchReimbursementDetail,
|
||||
precheckReimbursement,
|
||||
submitReimbursement,
|
||||
updateReimbursementItem,
|
||||
uploadReimbursementAttachments,
|
||||
type ReimbursementItem,
|
||||
type ReimbursementStatus,
|
||||
} from "../api/reimbursements";
|
||||
@@ -77,7 +81,7 @@ const INVOICE_STATUS_LABEL: Record<string, string> = {
|
||||
pending_ocr: "识别中",
|
||||
ocr_failed: "识别失败",
|
||||
available: "可用",
|
||||
bound: "已绑定",
|
||||
bound: "已添加",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
@@ -100,6 +104,13 @@ function formatDate(value: string | null | undefined) {
|
||||
return value || "未填写";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function formatInvoiceTitle(invoice: {
|
||||
invoiceCode?: string | null;
|
||||
invoiceNumber?: string | null;
|
||||
@@ -128,6 +139,7 @@ export function MobileReimbursementDetailPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
|
||||
const [itemError, setItemError] = useState("");
|
||||
@@ -141,6 +153,7 @@ export function MobileReimbursementDetailPage() {
|
||||
const [invoiceSearch, setInvoiceSearch] = useState("");
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
||||
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
||||
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
|
||||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||||
@@ -168,6 +181,7 @@ export function MobileReimbursementDetailPage() {
|
||||
const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice);
|
||||
const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
||||
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense));
|
||||
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
|
||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||
|
||||
const bindableInvoicesQuery = useQuery({
|
||||
@@ -182,6 +196,13 @@ export function MobileReimbursementDetailPage() {
|
||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
const pendingInvoiceStatusesQuery = useQuery({
|
||||
enabled: pendingOcrInvoiceIds.length > 0,
|
||||
queryKey: ["invoice-pool", "status", pendingOcrInvoiceIds],
|
||||
queryFn: () => fetchInvoiceStatuses(pendingOcrInvoiceIds),
|
||||
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
setInvoiceSearch(invoiceSearchInput.trim());
|
||||
@@ -205,23 +226,6 @@ export function MobileReimbursementDetailPage() {
|
||||
]);
|
||||
};
|
||||
|
||||
const resolveDefaultLinkedItemId = (invoice?: BindableInvoiceSummary | null) => {
|
||||
if (!detail?.items.length) {
|
||||
return "";
|
||||
}
|
||||
if (detail.items.length === 1) {
|
||||
return detail.items[0].id;
|
||||
}
|
||||
|
||||
const invoiceAmount = Number(invoice?.totalAmount ?? NaN);
|
||||
if (!Number.isFinite(invoiceAmount)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount);
|
||||
return matchedItems.length === 1 ? matchedItems[0].id : "";
|
||||
};
|
||||
|
||||
const startCreateItem = () => {
|
||||
setItemForm({
|
||||
mode: "create",
|
||||
@@ -348,14 +352,14 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
if (!failed.length) {
|
||||
setInvoiceActionError("");
|
||||
setInvoiceActionMessage(`已绑定 ${succeededIds.length} 张发票`);
|
||||
toast.success(`已绑定 ${succeededIds.length} 张发票`);
|
||||
setInvoiceActionMessage(`已添加 ${succeededIds.length} 张发票`);
|
||||
toast.success(`已添加 ${succeededIds.length} 张发票`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "绑定失败"}`).join(";");
|
||||
const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "添加失败"}`).join(";");
|
||||
setInvoiceActionError(message);
|
||||
toast.error("部分发票绑定失败");
|
||||
toast.error("部分发票添加失败");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error);
|
||||
@@ -369,7 +373,7 @@ export function MobileReimbursementDetailPage() {
|
||||
onSuccess: async () => {
|
||||
await invalidateDetail();
|
||||
setInvoiceActionError("");
|
||||
toast.success("发票已解绑");
|
||||
toast.success("发票已移除");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error);
|
||||
@@ -401,21 +405,24 @@ export function MobileReimbursementDetailPage() {
|
||||
);
|
||||
uploadedCount += 1;
|
||||
|
||||
if (uploadResult.status === "available") {
|
||||
const linkedItemId = resolveDefaultLinkedItemId(null);
|
||||
if (uploadResult.uploadAction === "blocked" || !uploadResult.invoicePoolId) {
|
||||
failures.push({
|
||||
file,
|
||||
message: uploadResult.unavailableMessage ?? "该发票不可添加",
|
||||
});
|
||||
} else if (uploadResult.status === "available") {
|
||||
try {
|
||||
await bindInvoice(uploadResult.invoicePoolId, {
|
||||
reimbursementId,
|
||||
...(linkedItemId ? { linkedItemId } : {}),
|
||||
});
|
||||
await bindInvoice(uploadResult.invoicePoolId, { reimbursementId });
|
||||
boundCount += 1;
|
||||
} catch (error) {
|
||||
failures.push({ file, message: `上传成功,绑定失败:${resolveErrorMessage(error)}` });
|
||||
failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` });
|
||||
}
|
||||
} else if (uploadResult.status === "pending_ocr") {
|
||||
pendingIds.push(uploadResult.invoicePoolId);
|
||||
} else if (uploadResult.status === "ocr_failed") {
|
||||
failures.push({ file, message: "OCR 识别失败,请到发票池补录后再绑定" });
|
||||
failures.push({ file, message: uploadResult.unavailableMessage ?? "发票识别失败,请重新识别或手动处理" });
|
||||
} else {
|
||||
failures.push({ file, message: uploadResult.unavailableMessage ?? "该发票不可添加" });
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({ file, message: resolveErrorMessage(error) });
|
||||
@@ -438,7 +445,7 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
const messages: string[] = [];
|
||||
if (result.boundCount) {
|
||||
messages.push(`${result.boundCount} 张已绑定`);
|
||||
messages.push(`${result.boundCount} 张已添加`);
|
||||
}
|
||||
if (result.pendingIds.length) {
|
||||
messages.push(`${result.pendingIds.length} 张识别中`);
|
||||
@@ -464,6 +471,80 @@ export function MobileReimbursementDetailPage() {
|
||||
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
|
||||
});
|
||||
|
||||
const uploadAttachmentsMutation = useMutation({
|
||||
mutationFn: async (files: File[]) => {
|
||||
if (!files.length) {
|
||||
throw new Error("请选择附件材料");
|
||||
}
|
||||
return uploadReimbursementAttachments(reimbursementId, files);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateDetail();
|
||||
setSelectedAttachmentFiles([]);
|
||||
if (attachmentInputRef.current) {
|
||||
attachmentInputRef.current.value = "";
|
||||
}
|
||||
toast.success("附件材料已上传");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAttachmentMutation = useMutation({
|
||||
mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
|
||||
onSuccess: async () => {
|
||||
await invalidateDetail();
|
||||
toast.success("附件材料已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const openAttachmentMutation = useMutation({
|
||||
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
|
||||
onSuccess: (result) => {
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const statuses = pendingInvoiceStatusesQuery.data?.items ?? [];
|
||||
if (!statuses.length || bindInvoicesMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableIds = statuses
|
||||
.filter((item) => item.status === "available")
|
||||
.map((item) => item.id);
|
||||
const finishedIds = new Set(
|
||||
statuses
|
||||
.filter((item) => item.status !== "pending_ocr")
|
||||
.map((item) => item.id),
|
||||
);
|
||||
const failedMessages = statuses
|
||||
.filter((item) => item.status === "ocr_failed" || item.status === "archived")
|
||||
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.message}`);
|
||||
|
||||
if (failedMessages.length) {
|
||||
setInvoiceActionError(failedMessages.join(";"));
|
||||
}
|
||||
|
||||
if (availableIds.length) {
|
||||
bindInvoicesMutation.mutate(availableIds.map((invoiceId) => ({ invoiceId })));
|
||||
}
|
||||
|
||||
if (finishedIds.size) {
|
||||
setPendingOcrInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId)));
|
||||
}
|
||||
}, [bindInvoicesMutation, pendingInvoiceStatusesQuery.data?.items]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!detail) {
|
||||
return;
|
||||
@@ -529,21 +610,14 @@ export function MobileReimbursementDetailPage() {
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
[invoice.id]: previous[invoice.id] ?? resolveDefaultLinkedItemId(invoice),
|
||||
[invoice.id]: previous[invoice.id] ?? "",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => {
|
||||
setSelectedInvoices((previous) => ({
|
||||
...previous,
|
||||
[invoiceId]: linkedItemId,
|
||||
}));
|
||||
};
|
||||
|
||||
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
|
||||
if (!linkedItemId) {
|
||||
return "整单";
|
||||
return "自动生成或匹配明细";
|
||||
}
|
||||
const item = detail?.items.find((candidate) => candidate.id === linkedItemId);
|
||||
return item ? itemLabel(item) : "明细";
|
||||
@@ -760,7 +834,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">绑定:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
|
||||
@@ -778,7 +852,7 @@ export function MobileReimbursementDetailPage() {
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
解绑
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</article>
|
||||
@@ -791,6 +865,98 @@ export function MobileReimbursementDetailPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-attachments">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900">附件材料</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">合同、订单、付款截图或情况说明</p>
|
||||
</div>
|
||||
{canManageAttachments ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
>
|
||||
选择
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canManageAttachments ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
{selectedAttachmentFiles.length ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{selectedAttachmentFiles.slice(0, 4).map((file) => (
|
||||
<div key={`${file.name}-${file.size}`} className="truncate">
|
||||
{file.name} · {Math.ceil(file.size / 1024)} KB
|
||||
</div>
|
||||
))}
|
||||
{selectedAttachmentFiles.length > 4 ? <div>另有 {selectedAttachmentFiles.length - 4} 个文件</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
|
||||
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
|
||||
>
|
||||
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(detail.attachments ?? []).length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{(detail.attachments ?? []).map((attachment) => (
|
||||
<article key={attachment.id} className="rounded-lg border border-zinc-200 px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={openAttachmentMutation.isPending}
|
||||
onClick={() => openAttachmentMutation.mutate(attachment.id)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
{canManageAttachments ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={deleteAttachmentMutation.isPending}
|
||||
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||||
暂无附件材料
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-actions">
|
||||
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
|
||||
{actionError ? (
|
||||
@@ -833,7 +999,7 @@ export function MobileReimbursementDetailPage() {
|
||||
>
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>添加发票</DialogTitle>
|
||||
<DialogDescription>可一次上传多张,识别完成后再绑定。</DialogDescription>
|
||||
<DialogDescription>可一次上传多张,识别完成后会自动添加。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1">
|
||||
@@ -900,7 +1066,7 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
{pendingOcrInvoiceIds.length ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||
{pendingOcrInvoiceIds.length} 张发票正在识别。识别完成后可在“已有发票”中绑定。
|
||||
{pendingOcrInvoiceIds.length} 张发票正在识别,完成后会自动刷新并添加。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -940,7 +1106,7 @@ export function MobileReimbursementDetailPage() {
|
||||
) : null}
|
||||
{!bindableInvoicesQuery.isLoading && !bindableInvoicesQuery.isError && !(bindableInvoicesQuery.data?.items ?? []).length ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||||
暂无可绑定发票
|
||||
暂无可添加发票
|
||||
</div>
|
||||
) : null}
|
||||
{(bindableInvoicesQuery.data?.items ?? []).map((invoice) => {
|
||||
@@ -965,18 +1131,9 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
{checked ? (
|
||||
<select
|
||||
className="select-std mt-3"
|
||||
value={selectedInvoices[invoice.id] ?? ""}
|
||||
onChange={(event) => updateSelectedInvoiceItem(invoice.id, event.target.value)}
|
||||
>
|
||||
<option value="">绑定整单</option>
|
||||
{detail.items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{itemLabel(item)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
|
||||
添加后自动生成或匹配报销明细
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
@@ -989,14 +1146,14 @@ export function MobileReimbursementDetailPage() {
|
||||
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
|
||||
onClick={() =>
|
||||
bindInvoicesMutation.mutate(
|
||||
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
|
||||
selectedInvoiceEntries.map(([invoiceId]) => ({
|
||||
invoiceId,
|
||||
linkedItemId: linkedItemId || undefined,
|
||||
linkedItemId: undefined,
|
||||
})),
|
||||
)
|
||||
}
|
||||
>
|
||||
{bindInvoicesMutation.isPending ? "绑定中..." : `绑定选中发票 ${selectedInvoiceEntries.length}`}
|
||||
{bindInvoicesMutation.isPending ? "添加中..." : `添加选中发票 ${selectedInvoiceEntries.length}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1050,7 +1207,7 @@ export function MobileReimbursementDetailPage() {
|
||||
open={confirmDeleteReimbursement}
|
||||
onOpenChange={setConfirmDeleteReimbursement}
|
||||
title="删除草稿?"
|
||||
description="删除后无法恢复,已绑定发票会回到可用状态。"
|
||||
description="删除后无法恢复,已添加发票会回到可用状态。"
|
||||
variant="destructive"
|
||||
confirmLabel="删除草稿"
|
||||
onConfirm={() => deleteReimbursementMutation.mutate()}
|
||||
|
||||
在新工单中引用
屏蔽一个用户