R102/A1-A8 优化报销主流程

这个提交包含在:
2026-05-06 23:06:12 +08:00
父节点 ed35967a65
当前提交 9a9def6286
共修改 10 个文件,包含 608 行新增和 54 行删除
+90 -12
查看文件
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
@@ -9,6 +9,7 @@ import { exportReimbursementJournal, previewReimbursementJournal } from "../api/
import { fetchProjects } from "../api/projects";
import {
createReimbursementItem,
deleteReimbursement,
deleteReimbursementItem,
fetchReimbursementDetail,
payReimbursement,
@@ -21,6 +22,7 @@ import {
} from "../api/reimbursements";
import {
bindInvoice,
type BindableInvoiceSummary,
fetchBindableInvoices,
fetchInvoicePoolDetail,
unbindInvoice,
@@ -49,6 +51,7 @@ import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds";
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
import {
@@ -110,6 +113,7 @@ function resolveErpSyncStatus(
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
const { permissions, me } = useAuth();
const queryClient = useQueryClient();
@@ -118,6 +122,7 @@ export function ReimbursementDetailPage() {
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
const [itemError, setItemError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
@@ -156,6 +161,7 @@ export function ReimbursementDetailPage() {
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const reimbursementPrecheckQuery = useQuery({
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
@@ -343,6 +349,26 @@ export function ReimbursementDetailPage() {
},
});
const deleteReimbursementMutation = useMutation({
mutationFn: async () => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
return deleteReimbursement(reimbursementId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
toast.success("草稿已删除");
navigate("/reimbursements?status=draft");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "删除草稿失败");
setActionError(message);
toast.error(message);
},
});
const projectLinkMutation = useMutation({
mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId),
onSuccess: async () => {
@@ -568,9 +594,15 @@ export function ReimbursementDetailPage() {
const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id));
const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId));
if (selectableIds.length) {
const invoiceById = new Map((refreshed.data?.items ?? []).map((item) => [item.id, item]));
setSelectedInvoices((previous) => ({
...previous,
...Object.fromEntries(selectableIds.map((invoiceId) => [invoiceId, previous[invoiceId] ?? ""])),
...Object.fromEntries(
selectableIds.map((invoiceId) => [
invoiceId,
previous[invoiceId] ?? resolveDefaultLinkedItemId(invoiceById.get(invoiceId)),
]),
),
}));
}
@@ -579,7 +611,7 @@ export function ReimbursementDetailPage() {
...result.ocrFailures,
];
if (result.pendingIds.length) {
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 处理中,请稍后刷新列表或到票据中心查看`);
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 识别中,可稍后刷新`);
}
if (messages.length) {
@@ -632,6 +664,7 @@ export function ReimbursementDetailPage() {
}
const detail = detailQuery.data;
const displayCode = resolveReimbursementDisplayCode(detail);
const isPaidReimbursement = detail.status === "paid";
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
const budgetHint = detail.budgetHint;
@@ -644,6 +677,10 @@ export function ReimbursementDetailPage() {
canUploadInvoice &&
canViewOwnInvoice;
const canUnbindInvoices = canEditItems && (isReimbursementParticipant || canViewAllExpense);
const canDeleteReimbursement =
detail.status === "draft" &&
canDeleteExpense &&
(isReimbursementParticipant || canViewAllExpense);
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
@@ -749,6 +786,24 @@ export function ReimbursementDetailPage() {
}
};
const resolveDefaultLinkedItemId = (invoice: BindableInvoiceSummary | null | undefined) => {
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 toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
setSelectedInvoices((previous) => {
if (!checked) {
@@ -757,9 +812,10 @@ export function ReimbursementDetailPage() {
return next;
}
const invoice = (bindableInvoicesQuery.data?.items ?? []).find((item) => item.id === invoiceId);
return {
...previous,
[invoiceId]: previous[invoiceId] ?? "",
[invoiceId]: previous[invoiceId] ?? resolveDefaultLinkedItemId(invoice),
};
});
};
@@ -800,8 +856,8 @@ export function ReimbursementDetailPage() {
return (
<PageScaffold>
<EntityOverviewHero
title={`报销详情 ${detail.formNumber ?? detail.id}`}
subtitle="查看、编辑、提交。"
title={`报销详情 ${displayCode}`}
subtitle="明细、发票和审批。"
meta={(
<>
<StatusBadge status={detail.status} />
@@ -819,6 +875,17 @@ export function ReimbursementDetailPage() {
<Button asChild variant="secondary" data-testid="reimbursement-back">
<Link to="/reimbursements">返回列表</Link>
</Button>
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
)}
/>
@@ -848,7 +915,7 @@ export function ReimbursementDetailPage() {
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
<div>状态:{detail.status}</div>
<div>报销ID:{detail.id}</div>
<div>报销编号:{displayCode}</div>
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
<div>金额:{detail.totalAmount ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
@@ -1248,7 +1315,7 @@ export function ReimbursementDetailPage() {
<div className="space-y-6">
<DetailSideMeta title="报销摘要">
<div>报销编号:{detail.formNumber ?? detail.id}</div>
<div>报销编号:{displayCode}</div>
<div>报销人:{detail.owner?.name ?? "-"}</div>
<div>提交人:{detail.submitter?.name ?? "-"}</div>
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
@@ -1310,7 +1377,7 @@ export function ReimbursementDetailPage() {
>
<DialogHeader>
<DialogTitle>添加发票</DialogTitle>
<DialogDescription>选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。</DialogDescription>
<DialogDescription>选择可用发票,或上传新发票。</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -1376,6 +1443,7 @@ export function ReimbursementDetailPage() {
<div className="text-xs text-zinc-500">绑定到明细(可选)</div>
<select
className="select-std mt-1"
data-testid={`reimbursement-invoice-linked-item-${item.id}`}
value={selectedInvoices[item.id] ?? ""}
disabled={!isSelected}
onChange={(event) => updateSelectedInvoiceItem(item.id, event.target.value)}
@@ -1413,7 +1481,7 @@ export function ReimbursementDetailPage() {
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-4">
<div className="space-y-1">
<div className="text-sm font-medium text-zinc-900">或者上传新发票</div>
<p className="text-xs text-zinc-500">上传后会自动使用当前报销人的 owner 信息,并等待 OCR 识别完成。</p>
<p className="text-xs text-zinc-500">支持 PDF 或图片,可多选。</p>
</div>
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
@@ -1444,7 +1512,7 @@ export function ReimbursementDetailPage() {
>
{uploadInvoiceMutation.isPending
? `上传中 ${invoiceUploadProgress.completed}/${invoiceUploadProgress.total}`
: "上传并等待 OCR"}
: "上传发票"}
</Button>
</div>
@@ -1461,7 +1529,7 @@ export function ReimbursementDetailPage() {
{pendingOcrInvoiceIds.length ? (
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
{pendingOcrInvoiceIds.length} 张发票正在识别,完成后自动刷新。
{pendingOcrInvoiceIds.length} 张发票正在 OCR 识别,可稍后刷新。
</p>
) : null}
</div>
@@ -1566,6 +1634,16 @@ export function ReimbursementDetailPage() {
confirmLabel="删除"
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
/>
<ConfirmDialog
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title={`确认删除 ${displayCode}?`}
description="删除后无法恢复,已绑定发票会回到可用状态。"
variant="destructive"
confirmLabel="删除草稿"
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
/>
</PageScaffold>
);
}