feat(报销): 收敛只读内容与审批上下文 [09-02-reimbursement-readonly-content-consistency]
这个提交包含在:
@@ -25,7 +25,7 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
|
||||
{slots.status}
|
||||
<span className="info-chip">报销人:{model.header.ownerName}</span>
|
||||
<span className="info-chip">日期:{formatDisplayDate(model.header.reimbursementDate)}</span>
|
||||
<span className="info-chip">合计:{formatMoney(model.header.totalAmount)}</span>
|
||||
<span className="info-chip">合计:{formatMoney(model.amountSummary.authoritativeTotalAmount)}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
@@ -36,13 +36,15 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
|
||||
{slots.beforeEvidence}
|
||||
<SectionPanel title="报销内容" className="scroll-mt-24">
|
||||
<div id="reimbursement-workspace-evidence">
|
||||
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} />
|
||||
<ReimbursementItemEvidenceList amountSummary={model.amountSummary} evidence={model.evidence} capabilities={capabilities} />
|
||||
</div>
|
||||
</SectionPanel>
|
||||
{slots.afterEvidence}
|
||||
{slots.timeline}
|
||||
</BusinessDetailMain>
|
||||
<BusinessDetailAside>{slots.actionPanel}</BusinessDetailAside>
|
||||
<BusinessDetailAside>
|
||||
{slots.actionPanel}
|
||||
{slots.asideContext}
|
||||
</BusinessDetailAside>
|
||||
</BusinessDetailWorkspace>
|
||||
</PageScaffold>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ReimbursementInvoiceEvidence,
|
||||
ReimbursementItemEvidence,
|
||||
} from "../../lib/reimbursementApprovalProjection";
|
||||
import { reimbursementAmountIntegrityMessage, type ReimbursementAmountSummary } from "../../lib/reimbursementWorkspace";
|
||||
|
||||
export type ReimbursementEvidenceCapabilities = {
|
||||
onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
|
||||
@@ -21,11 +22,17 @@ export type ReimbursementEvidenceCapabilities = {
|
||||
};
|
||||
|
||||
type Props = {
|
||||
amountSummary: ReimbursementAmountSummary;
|
||||
evidence: ReimbursementApprovalEvidenceProjection;
|
||||
capabilities?: ReimbursementEvidenceCapabilities;
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
function AmountIntegrityMessage({ integrity }: Pick<ReimbursementAmountSummary, "integrity">) {
|
||||
const message = reimbursementAmountIntegrityMessage(integrity);
|
||||
return message ? <p className="mt-1 text-sm font-medium text-red-700" role="alert">{message}</p> : null;
|
||||
}
|
||||
|
||||
function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) {
|
||||
if (status === "ocr_failed") return "该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。";
|
||||
if (status === "manual_review") return "票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。";
|
||||
@@ -61,7 +68,7 @@ function ItemIssues({ item }: { item: ReimbursementItemEvidence }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseItemsTable({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
|
||||
function ExpenseItemsTable({ amountSummary, items, capabilities }: { amountSummary: ReimbursementAmountSummary; items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
|
||||
const editable = Boolean(capabilities.onEditItem);
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-200" data-testid="reimbursement-expense-items-table">
|
||||
@@ -93,12 +100,22 @@ function ExpenseItemsTable({ items, capabilities }: { items: ReimbursementItemEv
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="border-t border-zinc-300 bg-zinc-50" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
|
||||
<tr>
|
||||
<th className="px-3 py-3 text-right font-medium text-zinc-700" colSpan={4} scope="row">明细合计</th>
|
||||
<td aria-label="服务端权威明细合计" className="px-3 py-3 text-right font-semibold tabular-nums text-zinc-950">
|
||||
{formatMoney(amountSummary.authoritativeTotalAmount)}
|
||||
</td>
|
||||
<td className="px-3 py-3" colSpan={editable ? 2 : 1} />
|
||||
</tr>
|
||||
{amountSummary.integrity !== "ready" ? <tr><td className="px-3 pb-3" colSpan={editable ? 7 : 6}><AmountIntegrityMessage integrity={amountSummary.integrity} /></td></tr> : null}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
|
||||
function ExpenseItemCards({ amountSummary, items, capabilities }: { amountSummary: ReimbursementAmountSummary; items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
|
||||
return <div className="space-y-3">{items.map((item, index) => {
|
||||
const hasBlockingIssue = itemHasBlockingIssue(item);
|
||||
return (
|
||||
@@ -110,7 +127,12 @@ function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvi
|
||||
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
|
||||
</article>
|
||||
);
|
||||
})}</div>;
|
||||
})}
|
||||
<div className="flex items-start justify-between gap-3 border-t border-zinc-200 pt-3" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
|
||||
<div className="min-w-0"><p className="text-sm font-medium text-zinc-700">明细合计</p><AmountIntegrityMessage integrity={amountSummary.integrity} /></div>
|
||||
<p aria-label="服务端权威明细合计" className="shrink-0 font-semibold tabular-nums text-zinc-950">{formatMoney(amountSummary.authoritativeTotalAmount)}</p>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function InvoiceMaterialRow({ invoice, capabilities, linkTargets }: { invoice: ReimbursementInvoiceEvidence; capabilities: ReimbursementEvidenceCapabilities; linkTargets: ReimbursementItemEvidence[] }) {
|
||||
@@ -153,13 +175,21 @@ function AttachmentMaterialRow({ attachment, capabilities }: { attachment: Reimb
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) {
|
||||
export function ReimbursementItemEvidenceList({ amountSummary, evidence, capabilities = {}, mobile = false }: Props) {
|
||||
const linkedInvoices = evidence.items.flatMap((item) => item.invoices);
|
||||
return (
|
||||
<div className="space-y-6" data-testid="reimbursement-approval-evidence">
|
||||
<section className="space-y-3" data-testid="reimbursement-expense-items">
|
||||
<h3 className="font-medium text-zinc-900">费用明细</h3>
|
||||
{evidence.items.length ? (mobile ? <ExpenseItemCards capabilities={capabilities} items={evidence.items} /> : <ExpenseItemsTable capabilities={capabilities} items={evidence.items} />) : <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>}
|
||||
{evidence.items.length ? (mobile ? <ExpenseItemCards amountSummary={amountSummary} capabilities={capabilities} items={evidence.items} /> : <ExpenseItemsTable amountSummary={amountSummary} capabilities={capabilities} items={evidence.items} />) : (
|
||||
<div className="space-y-3">
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>
|
||||
<div className="flex items-start justify-between gap-3 border-t border-zinc-200 pt-3" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
|
||||
<div><p className="text-sm font-medium text-zinc-700">明细合计</p><AmountIntegrityMessage integrity={amountSummary.integrity} /></div>
|
||||
<p aria-label="服务端权威明细合计" className="font-semibold tabular-nums text-zinc-950">{formatMoney(amountSummary.authoritativeTotalAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="space-y-4" data-testid="approval-materials">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="font-medium text-zinc-900">票据与附件</h3>{capabilities.onOpenMaterialBundle ? <Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">查看完整材料</Button> : null}</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
|
||||
<section className="mobile-card mobile-focus-card mobile-page-stack">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500">报销合计</p>
|
||||
<p className="mt-1 text-3xl font-semibold text-zinc-950">{formatMoney(model.header.totalAmount)}</p>
|
||||
<p className="mt-1 text-3xl font-semibold text-zinc-950">{formatMoney(model.amountSummary.authoritativeTotalAmount)}</p>
|
||||
</div>
|
||||
<div className="mobile-info-grid">
|
||||
<div className="mobile-list-card">
|
||||
@@ -48,7 +48,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
|
||||
{slots.beforeEvidence}
|
||||
<section className="mobile-card scroll-mt-20" data-testid="reimbursement-workspace-evidence" id="reimbursement-workspace-evidence">
|
||||
<h2 className="mb-3 text-base font-semibold text-zinc-900">报销内容</h2>
|
||||
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} mobile />
|
||||
<ReimbursementItemEvidenceList amountSummary={model.amountSummary} evidence={model.evidence} capabilities={capabilities} mobile />
|
||||
</section>
|
||||
{slots.afterEvidence}
|
||||
{slots.timeline}
|
||||
|
||||
@@ -7,7 +7,6 @@ export type ReimbursementWorkspaceHeaderModel = {
|
||||
displayCode: string;
|
||||
ownerName: string;
|
||||
reimbursementDate: string | null;
|
||||
totalAmount: string | null;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
@@ -17,8 +16,14 @@ export type ReimbursementWorkspaceIssueAnchor = {
|
||||
severity: "blocking" | "review" | "info";
|
||||
};
|
||||
|
||||
export type ReimbursementAmountSummary = {
|
||||
authoritativeTotalAmount: string | null;
|
||||
integrity: "ready" | "mismatch" | "unavailable";
|
||||
};
|
||||
|
||||
export type ReimbursementWorkspaceModel = {
|
||||
header: ReimbursementWorkspaceHeaderModel;
|
||||
amountSummary: ReimbursementAmountSummary;
|
||||
evidence: ReimbursementApprovalEvidenceProjection;
|
||||
issueAnchors: ReimbursementWorkspaceIssueAnchor[];
|
||||
};
|
||||
@@ -31,27 +36,69 @@ export type ReimbursementWorkspaceSlots = {
|
||||
afterEvidence?: ReactNode;
|
||||
timeline?: ReactNode;
|
||||
actionPanel?: ReactNode;
|
||||
asideContext?: ReactNode;
|
||||
mobileActions?: ReactNode;
|
||||
transientFeedback?: ReactNode;
|
||||
};
|
||||
|
||||
export function buildReimbursementWorkspaceHeader(
|
||||
detail: Pick<ReimbursementDetail, "formNumber" | "owner" | "reimbursementDate" | "totalAmount" | "description" | "id">,
|
||||
detail: Pick<ReimbursementDetail, "formNumber" | "owner" | "reimbursementDate" | "description" | "id">,
|
||||
): ReimbursementWorkspaceHeaderModel {
|
||||
return {
|
||||
displayCode: resolveReimbursementDisplayCode(detail),
|
||||
ownerName: detail.owner?.name ?? "-",
|
||||
reimbursementDate: detail.reimbursementDate,
|
||||
totalAmount: detail.totalAmount,
|
||||
description: detail.description,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMoneyToMinorUnits(value: string | null | undefined): bigint | null {
|
||||
if (typeof value !== "string" || !/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(value)) return null;
|
||||
const [whole, fraction = ""] = value.split(".");
|
||||
return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"));
|
||||
}
|
||||
|
||||
export function buildReimbursementAmountSummary(
|
||||
authoritativeTotalAmount: string | null,
|
||||
items: Array<{ amount: string }>,
|
||||
): ReimbursementAmountSummary {
|
||||
const authoritativeMinorUnits = parseMoneyToMinorUnits(authoritativeTotalAmount);
|
||||
const itemMinorUnits = items.map((item) => parseMoneyToMinorUnits(item.amount));
|
||||
if (authoritativeMinorUnits === null || itemMinorUnits.some((value) => value === null)) {
|
||||
return { authoritativeTotalAmount, integrity: "unavailable" };
|
||||
}
|
||||
const derivedMinorUnits = itemMinorUnits.reduce<bigint>((sum, value) => sum + (value ?? 0n), 0n);
|
||||
return {
|
||||
authoritativeTotalAmount,
|
||||
integrity: authoritativeMinorUnits === derivedMinorUnits ? "ready" : "mismatch",
|
||||
};
|
||||
}
|
||||
|
||||
export function reimbursementAmountIntegrityMessage(integrity: ReimbursementAmountSummary["integrity"]) {
|
||||
if (integrity === "mismatch") return "报销总额与明细汇总不一致,请联系管理员";
|
||||
if (integrity === "unavailable") return "明细金额无法完整核对,请联系管理员";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildReimbursementWorkspaceIssueAnchors(
|
||||
evidence: ReimbursementApprovalEvidenceProjection,
|
||||
options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {},
|
||||
options: {
|
||||
amountIntegrity?: ReimbursementAmountSummary["integrity"];
|
||||
snapshotMissing?: boolean;
|
||||
snapshotInvalid?: boolean;
|
||||
} = {},
|
||||
): ReimbursementWorkspaceIssueAnchor[] {
|
||||
const anchors: ReimbursementWorkspaceIssueAnchor[] = [];
|
||||
const amountIntegrityMessage = options.amountIntegrity
|
||||
? reimbursementAmountIntegrityMessage(options.amountIntegrity)
|
||||
: null;
|
||||
if (amountIntegrityMessage) {
|
||||
anchors.push({
|
||||
id: "reimbursement-workspace-amount-summary",
|
||||
label: amountIntegrityMessage,
|
||||
severity: "blocking",
|
||||
});
|
||||
}
|
||||
if (options.snapshotInvalid) {
|
||||
anchors.push({ id: "reimbursement-workspace-snapshot", label: "提交记录无法读取", severity: "blocking" });
|
||||
} else if (options.snapshotMissing) {
|
||||
@@ -94,3 +141,23 @@ export function buildReimbursementWorkspaceIssueAnchors(
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
export function buildReimbursementWorkspaceModel(
|
||||
detail: Pick<
|
||||
ReimbursementDetail,
|
||||
"formNumber" | "owner" | "reimbursementDate" | "totalAmount" | "description" | "id"
|
||||
>,
|
||||
evidence: ReimbursementApprovalEvidenceProjection,
|
||||
options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {},
|
||||
): ReimbursementWorkspaceModel {
|
||||
const amountSummary = buildReimbursementAmountSummary(detail.totalAmount, evidence.items);
|
||||
return {
|
||||
header: buildReimbursementWorkspaceHeader(detail),
|
||||
amountSummary,
|
||||
evidence,
|
||||
issueAnchors: buildReimbursementWorkspaceIssueAnchors(evidence, {
|
||||
...options,
|
||||
amountIntegrity: amountSummary.integrity,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ import { Textarea } from "../components/ui/textarea";
|
||||
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
|
||||
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
|
||||
import { buildReimbursementWorkspaceModel } from "../lib/reimbursementWorkspace";
|
||||
import {
|
||||
formatInvoiceDisplayTitle,
|
||||
resolveInvoicePartyText,
|
||||
@@ -909,11 +909,7 @@ export function MobileReimbursementDetailPage() {
|
||||
budgetCheckStatus: "not_applicable",
|
||||
parseStatus: "ready",
|
||||
});
|
||||
const workspaceModel = {
|
||||
header: buildReimbursementWorkspaceHeader(detail),
|
||||
evidence: workspaceEvidence,
|
||||
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
|
||||
};
|
||||
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
|
||||
const workspaceCapabilities = {
|
||||
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
|
||||
onOpenAttachment: (attachment: { id: string }) => {
|
||||
|
||||
@@ -84,7 +84,7 @@ import {
|
||||
} from "./reimbursement-detail/ReimbursementItemEditor";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
|
||||
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
|
||||
import { buildReimbursementWorkspaceModel } from "../lib/reimbursementWorkspace";
|
||||
import {
|
||||
focusFirstInvoiceGuardIssue,
|
||||
parseReimbursementInvoiceGuardIssues,
|
||||
@@ -1042,11 +1042,7 @@ export function ReimbursementDetailPage() {
|
||||
budgetCheckStatus: "not_applicable",
|
||||
parseStatus: "ready",
|
||||
});
|
||||
const workspaceModel = {
|
||||
header: buildReimbursementWorkspaceHeader(detail),
|
||||
evidence: workspaceEvidence,
|
||||
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
|
||||
};
|
||||
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
|
||||
const workspaceCapabilities = {
|
||||
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
|
||||
onOpenAttachment: (attachment: { id: string }) => {
|
||||
|
||||
@@ -36,10 +36,7 @@ import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDispla
|
||||
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance";
|
||||
import { formatDisplayDateTime } from "../../lib/displayFormat";
|
||||
import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection";
|
||||
import {
|
||||
buildReimbursementWorkspaceHeader,
|
||||
buildReimbursementWorkspaceIssueAnchors,
|
||||
} from "../../lib/reimbursementWorkspace";
|
||||
import { buildReimbursementWorkspaceModel, reimbursementAmountIntegrityMessage } from "../../lib/reimbursementWorkspace";
|
||||
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
|
||||
import { isWecomWebView } from "../../lib/runtimeContext";
|
||||
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../../lib/workflowActionConfirm";
|
||||
@@ -121,16 +118,17 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
}, [reimbursementDetailQuery.data, snapshot]);
|
||||
const model = useMemo(() => {
|
||||
if (!reimbursementDetailQuery.data || !evidence) return null;
|
||||
return {
|
||||
header: buildReimbursementWorkspaceHeader(reimbursementDetailQuery.data),
|
||||
evidence,
|
||||
issueAnchors: buildReimbursementWorkspaceIssueAnchors(evidence, {
|
||||
snapshotInvalid: snapshot?.parseStatus === "invalid",
|
||||
snapshotMissing: snapshot?.budgetCheckStatus === "missing",
|
||||
}),
|
||||
};
|
||||
return buildReimbursementWorkspaceModel(reimbursementDetailQuery.data, evidence, {
|
||||
snapshotInvalid: snapshot?.parseStatus === "invalid",
|
||||
snapshotMissing: snapshot?.budgetCheckStatus === "missing",
|
||||
});
|
||||
}, [evidence, reimbursementDetailQuery.data, snapshot?.budgetCheckStatus, snapshot?.parseStatus]);
|
||||
const decisionReady = Boolean(online && model && snapshot?.parseStatus === "ready");
|
||||
const decisionReady = Boolean(
|
||||
online
|
||||
&& model
|
||||
&& model.amountSummary.integrity === "ready"
|
||||
&& snapshot?.parseStatus === "ready",
|
||||
);
|
||||
const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]);
|
||||
const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer");
|
||||
const approverQuery = useQuery({
|
||||
@@ -176,7 +174,13 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => {
|
||||
if (!decisionReady) throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
|
||||
if (!decisionReady) {
|
||||
throw new Error(
|
||||
model
|
||||
? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,暂时不能处理"
|
||||
: "完整报销内容或提交时检查尚未就绪,暂时不能处理",
|
||||
);
|
||||
}
|
||||
if (action === "approve") return approveWorkflowTask(taskId);
|
||||
if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim());
|
||||
if (action === "return") return returnWorkflowTask(taskId, returnComment.trim());
|
||||
@@ -205,7 +209,11 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
|
||||
function requestAction(action: WorkflowConfirmAction) {
|
||||
if (!decisionReady) {
|
||||
setActionError(online ? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。" : "报销审批需联网核对完整报销内容后才能处理。");
|
||||
setActionError(!online
|
||||
? "报销审批需联网核对完整报销内容后才能处理。"
|
||||
: model
|
||||
? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。"
|
||||
: "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。");
|
||||
return;
|
||||
}
|
||||
const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : "";
|
||||
@@ -322,7 +330,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
);
|
||||
const timeline = (
|
||||
<SectionPanel title="处理历史">
|
||||
<div className="detail-timeline">
|
||||
<div className="detail-timeline" data-testid="reimbursement-workflow-history">
|
||||
{detail.history.length ? detail.history.map((item) => (
|
||||
<div className="detail-timeline-item" key={item.id}>
|
||||
<div className="font-medium text-zinc-900">{workflowActionLabel(item.action)} · {item.actorName || item.actorEmployeeNo}</div>
|
||||
@@ -433,8 +441,8 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
backAction: <Button asChild variant="ghost"><Link replace to={backTarget}>返回审批列表</Link></Button>,
|
||||
beforeEvidence: detail.task.status !== "pending" ? <p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">当前显示报销单最新版本;审批意见与提交检查保留历史语义。</p> : null,
|
||||
afterEvidence: snapshot?.budgetCheckStatus === "missing" ? <p className="text-sm text-zinc-500" id="reimbursement-workspace-snapshot">该次提交未留存预算快照。</p> : null,
|
||||
timeline,
|
||||
actionPanel,
|
||||
asideContext: timeline,
|
||||
}}
|
||||
/>
|
||||
{dialogs}
|
||||
|
||||
在新工单中引用
屏蔽一个用户