实现 B-2b 统一报销工作台迁移(reimbursement-form-shared-component-migration)

这个提交包含在:
2026-09-02 07:35:01 +08:00
父节点 6bca91a360
当前提交 abdd250325
共修改 38 个文件,包含 1850 行新增和 2723 行删除
@@ -0,0 +1,49 @@
import type { ReimbursementEvidenceCapabilities } from "./ReimbursementItemEvidenceList";
import { ReimbursementItemEvidenceList } from "./ReimbursementItemEvidenceList";
import type { ReimbursementWorkspaceModel, ReimbursementWorkspaceSlots } from "../../lib/reimbursementWorkspace";
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
import { BusinessDetailAside, BusinessDetailMain, BusinessDetailWorkspace } from "../patterns/BusinessDetailTemplate";
import { EntityOverviewHero } from "../patterns/EntityOverviewHero";
import { PageScaffold } from "../patterns/PageScaffold";
import { SectionPanel } from "../patterns/SectionPanel";
type Props = {
model: ReimbursementWorkspaceModel;
capabilities?: ReimbursementEvidenceCapabilities;
slots: ReimbursementWorkspaceSlots;
};
export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots }: Props) {
return (
<PageScaffold data-testid="reimbursement-desktop-workspace">
{slots.backAction}
<EntityOverviewHero
title={`报销单 ${model.header.displayCode}`}
subtitle={model.header.description || "未填写事由"}
meta={(
<>
{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>
</>
)}
/>
{slots.transientFeedback}
<BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
<BusinessDetailMain>
{slots.headerDetails}
{slots.beforeEvidence}
<SectionPanel title="明细与票据" className="scroll-mt-24">
<div id="reimbursement-workspace-evidence">
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} />
</div>
</SectionPanel>
{slots.afterEvidence}
{slots.timeline}
</BusinessDetailMain>
<BusinessDetailAside>{slots.actionPanel}</BusinessDetailAside>
</BusinessDetailWorkspace>
</PageScaffold>
);
}
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Button } from "../ui/button";
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
import type {
@@ -11,35 +12,48 @@ export type ReimbursementEvidenceCapabilities = {
onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onOpenAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onOpenMaterialBundle?: () => void;
onDeleteAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onReferenceItem?: (item: ReimbursementItemEvidence) => void;
onReferenceInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onEditItem?: (item: ReimbursementItemEvidence) => void;
onDeleteItem?: (item: ReimbursementItemEvidence) => void;
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void;
linkInvoicePending?: boolean;
linkInvoiceErrors?: Record<string, string>;
onUnlinkInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
};
type Props = {
evidence: ReimbursementApprovalEvidenceProjection;
capabilities?: ReimbursementEvidenceCapabilities;
mobile?: boolean;
};
function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) {
if (status === "verified") return "票面金额已读取";
if (status === "ocr_failed") return "票面金额未能读取,暂时不能完成核验";
return "票面金额需查看原件人工核对";
if (status === "ocr_failed") return "该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。";
if (status === "manual_review") return "票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。";
return null;
}
function InvoiceCard({
invoice,
capabilities,
linkTargets,
}: {
invoice: ReimbursementInvoiceEvidence;
capabilities: ReimbursementEvidenceCapabilities;
linkTargets: ReimbursementItemEvidence[];
}) {
const [selectedItemId, setSelectedItemId] = useState("");
const title = invoice.invoiceNumber || invoice.originalName || "未命名发票";
const hasBlockingIssue = !invoice.linkedItemId || invoice.amountEvidenceStatus === "ocr_failed";
return (
<article className="rounded-lg border border-zinc-200 bg-white p-3" data-testid="approval-invoice-evidence">
<article
className="rounded-lg border border-zinc-200 bg-white p-3"
data-testid="approval-invoice-evidence"
data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined}
tabIndex={hasBlockingIssue ? -1 : undefined}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="truncate text-sm font-medium text-zinc-900">{title}</h4>
@@ -48,13 +62,15 @@ function InvoiceCard({
{" · "}{formatDisplayDate(invoice.invoiceDate)}
{" · "}{formatMoney(invoice.totalAmount)}
</p>
<p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>
{evidenceStatusCopy(invoice.amountEvidenceStatus)}
</p>
{evidenceStatusCopy(invoice.amountEvidenceStatus) ? (
<p className={invoice.amountEvidenceStatus === "ocr_failed" ? "mt-1 text-xs text-red-700" : "mt-1 text-xs text-zinc-600"}>
{evidenceStatusCopy(invoice.amountEvidenceStatus)}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{capabilities.onOpenInvoice ? (
<Button className="min-h-11" onClick={() => capabilities.onOpenInvoice?.(invoice)} size="sm" type="button" variant="secondary">
<Button aria-label={`查看发票原件:${title}`} className="min-h-11" onClick={() => capabilities.onOpenInvoice?.(invoice)} size="sm" type="button" variant="secondary">
查看原件
</Button>
) : null}
@@ -64,7 +80,34 @@ function InvoiceCard({
</Button>
) : null}
{capabilities.onLinkInvoice && !invoice.linkedItemId ? (
<Button className="min-h-11" onClick={() => capabilities.onLinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">选择明细</Button>
<div className="grid min-w-[220px] gap-2">
<label className="text-xs font-medium text-zinc-700" htmlFor={`reimbursement-link-item-${invoice.id}`}>选择报销明细</label>
<select
className="select-std min-h-11"
disabled={capabilities.linkInvoicePending}
id={`reimbursement-link-item-${invoice.id}`}
onChange={(event) => setSelectedItemId(event.target.value)}
value={selectedItemId}
>
<option value="">请选择报销明细</option>
{linkTargets.map((item, index) => (
<option key={item.id} value={item.id}>明细 {index + 1} · {item.expenseType?.name ?? "未分类费用"} · {formatMoney(item.amount)}</option>
))}
</select>
<Button
className="min-h-11"
disabled={!selectedItemId || capabilities.linkInvoicePending}
onClick={() => capabilities.onLinkInvoice?.(invoice, selectedItemId)}
size="sm"
type="button"
variant="secondary"
>
{capabilities.linkInvoicePending ? "关联中..." : "确认关联"}
</Button>
{capabilities.linkInvoiceErrors?.[invoice.id] ? (
<p className="text-xs text-red-700" role="alert">{capabilities.linkInvoiceErrors[invoice.id]}</p>
) : null}
</div>
) : null}
{capabilities.onUnlinkInvoice && invoice.linkedItemId ? (
<Button className="min-h-11" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button>
@@ -75,12 +118,14 @@ function InvoiceCard({
);
}
export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: Props) {
export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) {
return (
<div className="space-y-5" data-testid="reimbursement-approval-evidence">
<div className="space-y-3">
{evidence.items.length ? evidence.items.map((item, index) => (
<article className="rounded-xl border border-zinc-200 bg-zinc-50/60 p-4" data-testid="approval-item-evidence" key={item.id}>
{evidence.items.length ? evidence.items.map((item, index) => {
const hasBlockingIssue = item.amountExceeded || item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed") || (item.expenseType?.requiresInvoice !== false && item.invoices.length === 0);
return (
<article className={mobile ? "mobile-list-card" : "rounded-xl border border-zinc-200 bg-zinc-50/60 p-4"} data-testid="approval-item-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} tabIndex={hasBlockingIssue ? -1 : undefined} key={item.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-zinc-500">明细 {index + 1}</p>
@@ -127,30 +172,30 @@ export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: P
当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。
</p>
) : null}
<div className="mt-4 space-y-2">
{item.invoices.length || item.expenseType?.requiresInvoice !== false ? <div className="mt-4 space-y-2">
<p className="text-sm font-medium text-zinc-800">所属票据({item.invoices.length})</p>
{item.invoices.length ? item.invoices.map((invoice) => (
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} />
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
)) : (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
这条明细尚无对应票据,请在作出审批决定前核对是否需要补充。
这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。
</p>
)}
</div>
</div> : null}
</article>
)) : (
); }) : (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>
)}
</div>
{evidence.unlinkedInvoices.length ? (
<section className="space-y-2 rounded-xl border border-red-200 bg-red-50 p-4" data-testid="approval-unlinked-invoices">
<section className="space-y-2 rounded-xl border border-red-200 bg-red-50 p-4" data-testid="approval-unlinked-invoices" id="reimbursement-workspace-unlinked">
<div>
<h3 className="font-medium text-red-900">尚未对应明细的票据({evidence.unlinkedInvoices.length})</h3>
<p className="mt-1 text-sm text-red-800">这些票据当前没有明确费用归属,审批前需要申请人处理或在意见中说明。</p>
<p className="mt-1 text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>
</div>
{evidence.unlinkedInvoices.map((invoice) => (
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} />
<InvoiceCard capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />
))}
</section>
) : null}
@@ -171,10 +216,15 @@ export function ReimbursementItemEvidenceList({ evidence, capabilities = {} }: P
<p className="text-xs text-zinc-500">{attachment.mimeType} · {(attachment.fileSize / 1024).toFixed(1)} KB</p>
</div>
{capabilities.onOpenAttachment ? (
<Button className="min-h-11" onClick={() => capabilities.onOpenAttachment?.(attachment)} size="sm" type="button" variant="secondary">
<Button aria-label={`查看补充附件:${attachment.originalName}`} className="min-h-11" onClick={() => capabilities.onOpenAttachment?.(attachment)} size="sm" type="button" variant="secondary">
查看附件
</Button>
) : null}
{capabilities.onDeleteAttachment ? (
<Button className="min-h-11" onClick={() => capabilities.onDeleteAttachment?.(attachment)} size="sm" type="button" variant="secondary">
删除附件
</Button>
) : null}
</div>
)) : <p className="text-sm text-zinc-500">未上传补充附件</p>}
</section>
@@ -0,0 +1,58 @@
import type { ReimbursementEvidenceCapabilities } from "./ReimbursementItemEvidenceList";
import { ReimbursementItemEvidenceList } from "./ReimbursementItemEvidenceList";
import type { ReimbursementWorkspaceModel, ReimbursementWorkspaceSlots } from "../../lib/reimbursementWorkspace";
import { formatDisplayDate, formatMoney } from "../../lib/displayFormat";
import { MobileDetailHeader } from "../mobile/MobileDetailHeader";
import { MOBILE_STICKY_ACTION_PAGE_CLASS } from "../patterns/MobileStickyActionBar";
import { PageScaffold } from "../patterns/PageScaffold";
type Props = {
model: ReimbursementWorkspaceModel;
capabilities?: ReimbursementEvidenceCapabilities;
slots: ReimbursementWorkspaceSlots;
fallbackTo: string;
};
export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots, fallbackTo }: Props) {
return (
<PageScaffold className={slots.mobileActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : undefined} data-testid="reimbursement-mobile-workspace">
<MobileDetailHeader
fallbackTo={fallbackTo}
status={slots.status}
subtitle="报销详情"
testId="mobile-reimbursement-detail-header"
title={model.header.displayCode}
/>
{slots.transientFeedback}
<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>
</div>
<div className="mobile-info-grid">
<div className="mobile-list-card">
<p className="text-xs text-zinc-500">报销人</p>
<p className="mt-1 font-medium text-zinc-900">{model.header.ownerName}</p>
</div>
<div className="mobile-list-card">
<p className="text-xs text-zinc-500">报销日期</p>
<p className="mt-1 font-medium text-zinc-900">{formatDisplayDate(model.header.reimbursementDate)}</p>
</div>
</div>
<div>
<p className="text-xs text-zinc-500">事由</p>
<p className="mt-1 text-sm leading-6 text-zinc-700">{model.header.description || "未填写事由"}</p>
</div>
{slots.headerDetails}
</section>
{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 />
</section>
{slots.afterEvidence}
{slots.timeline}
{slots.mobileActions}
</PageScaffold>
);
}
+96
查看文件
@@ -0,0 +1,96 @@
import type { ReactNode } from "react";
import type { ReimbursementDetail } from "../api/reimbursements";
import type { ReimbursementApprovalEvidenceProjection } from "./reimbursementApprovalProjection";
import { resolveReimbursementDisplayCode } from "./reimbursementDisplay";
export type ReimbursementWorkspaceHeaderModel = {
displayCode: string;
ownerName: string;
reimbursementDate: string | null;
totalAmount: string | null;
description: string | null;
};
export type ReimbursementWorkspaceIssueAnchor = {
id: string;
label: string;
severity: "blocking" | "review" | "info";
};
export type ReimbursementWorkspaceModel = {
header: ReimbursementWorkspaceHeaderModel;
evidence: ReimbursementApprovalEvidenceProjection;
issueAnchors: ReimbursementWorkspaceIssueAnchor[];
};
export type ReimbursementWorkspaceSlots = {
status: ReactNode;
backAction?: ReactNode;
headerDetails?: ReactNode;
beforeEvidence?: ReactNode;
afterEvidence?: ReactNode;
timeline?: ReactNode;
actionPanel?: ReactNode;
mobileActions?: ReactNode;
transientFeedback?: ReactNode;
};
export function buildReimbursementWorkspaceHeader(
detail: Pick<ReimbursementDetail, "formNumber" | "owner" | "reimbursementDate" | "totalAmount" | "description" | "id">,
): ReimbursementWorkspaceHeaderModel {
return {
displayCode: resolveReimbursementDisplayCode(detail),
ownerName: detail.owner?.name ?? "-",
reimbursementDate: detail.reimbursementDate,
totalAmount: detail.totalAmount,
description: detail.description,
};
}
export function buildReimbursementWorkspaceIssueAnchors(
evidence: ReimbursementApprovalEvidenceProjection,
options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {},
): ReimbursementWorkspaceIssueAnchor[] {
const anchors: ReimbursementWorkspaceIssueAnchor[] = [];
if (options.snapshotInvalid) {
anchors.push({ id: "reimbursement-workspace-snapshot", label: "提交记录无法读取", severity: "blocking" });
} else if (options.snapshotMissing) {
anchors.push({ id: "reimbursement-workspace-snapshot", label: "未留存预算快照", severity: "info" });
}
if (evidence.currentCheck.unlinkedInvoiceCount) {
anchors.push({
id: "reimbursement-workspace-unlinked",
label: `${evidence.currentCheck.unlinkedInvoiceCount} 张票据尚未选择明细`,
severity: "blocking",
});
}
if (evidence.currentCheck.missingInvoiceItemCount) {
anchors.push({
id: "reimbursement-workspace-evidence",
label: `${evidence.currentCheck.missingInvoiceItemCount} 条明细缺少票据`,
severity: "review",
});
}
if (evidence.currentCheck.amountUnavailableItemCount) {
anchors.push({
id: "reimbursement-workspace-evidence",
label: `${evidence.currentCheck.amountUnavailableItemCount} 条明细的票面金额未能读取`,
severity: "blocking",
});
}
if (evidence.currentCheck.manualReviewItemCount) {
anchors.push({
id: "reimbursement-workspace-evidence",
label: `${evidence.currentCheck.manualReviewItemCount} 条明细需查看原件核对金额`,
severity: "review",
});
}
if (evidence.currentCheck.amountExceededItemCount) {
anchors.push({
id: "reimbursement-workspace-evidence",
label: `${evidence.currentCheck.amountExceededItemCount} 条明细申报额高于票面金额`,
severity: "blocking",
});
}
return anchors;
}
+18 -193
查看文件
@@ -1,19 +1,14 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
import {
downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
fetchReimbursements,
type ReimbursementListItem,
} from "../api/reimbursements";
import { fetchInvoiceSignedUrl } from "../api/invoicePool";
import { isApiError } from "../api/types";
import {
approveWorkflowTask,
@@ -29,8 +24,6 @@ import {
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
@@ -46,13 +39,6 @@ import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import {
buildReimbursementApprovalEvidence,
buildReimbursementReturnReference,
} from "../lib/reimbursementApprovalProjection";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { cn } from "../lib/utils";
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
import {
@@ -362,14 +348,15 @@ function toEmployeeProgressCards(input: {
return [...reimbursementCards, ...leaveCards, ...purchaseCards].slice(0, 8);
}
export function MobileApprovalsPage() {
export function MobileApprovalsPage({ preloadedTaskDetail = null }: { preloadedTaskDetail?: WorkflowTaskDetail | null } = {}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const routeParams = useParams();
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || "";
const legacyQueryTaskId = routeParams.taskId ? "" : searchParams.get("taskId")?.trim() || "";
const deepLinkTaskId = routeParams.taskId?.trim() || legacyQueryTaskId;
const isPushDetail = Boolean(deepLinkTaskId);
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
@@ -469,7 +456,7 @@ export function MobileApprovalsPage() {
});
const detailQuery = useQuery({
enabled: Boolean(selectedTaskId) && canUseRemote,
enabled: Boolean(selectedTaskId) && !legacyQueryTaskId && canUseRemote && !preloadedTaskDetail,
queryKey: ["mobile", "workflow", "task-detail", selectedTaskId],
queryFn: () => fetchWorkflowTaskDetail(selectedTaskId),
});
@@ -560,56 +547,10 @@ export function MobileApprovalsPage() {
[filteredTaskCards, selectedTaskId],
);
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
const mobileReimbursementId = selectedTask?.reimbursement?.id ?? "";
const reimbursementDetailQuery = useQuery({
enabled: Boolean(mobileReimbursementId) && canUseRemote,
queryKey: ["reimbursements", mobileReimbursementId],
queryFn: () => fetchReimbursementDetail(mobileReimbursementId),
});
const reimbursementEvidence = useMemo(() => {
const snapshot = selectedTask?.workflowInstance?.contextData;
if (!reimbursementDetailQuery.data || !snapshot) return null;
return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, snapshot);
}, [reimbursementDetailQuery.data, selectedTask?.workflowInstance?.contextData]);
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openInvoiceMutation = useMutation({
mutationFn: async (invoiceId: string) => {
const result = await fetchInvoiceSignedUrl(invoiceId);
const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
if (!openedInWecom) openFileInBrowser(result.signedUrl);
},
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开发票失败,请重试")),
});
const openAttachmentMutation = useMutation({
mutationFn: async ({ attachment, reason }: {
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
reason?: string;
}) => {
if (isWecomWebView()) {
const result = await fetchReimbursementAttachmentPreviewUrl(mobileReimbursementId, attachment.id, reason);
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
return null;
}
return downloadReimbursementAttachment(mobileReimbursementId, attachment.id, reason);
},
onSuccess: (result) => {
if (!result) return;
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开附件失败,请重试")),
});
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(mobileReimbursementId, reason),
onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl),
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开完整材料失败,可逐份查看原件")),
});
const selectedTask = preloadedTaskDetail ?? detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
const selectedDetailReference = selectedDisplayReference ??
publicReference(selectedTask?.reimbursement?.formNumber) ??
publicReference(selectedTask?.purchaseRequest?.requestNo) ??
publicReference(selectedTask?.travelRequest?.requestNo);
const selectedDetailTitle = selectedTask
@@ -620,24 +561,18 @@ export function MobileApprovalsPage() {
})
: "";
const selectedApplicantName = selectedTaskCard?.applicantName ??
selectedTask?.reimbursement?.submitter?.name ??
selectedTask?.leaveRequest?.owner?.name ??
selectedTask?.attendanceCorrection?.owner?.name ??
selectedTask?.purchaseRequest?.applicant?.name ??
selectedTask?.travelRequest?.applicant?.name ??
null;
const selectedMetric = selectedTaskCard?.metric ??
selectedTask?.reimbursement?.totalAmount ??
(selectedTask?.leaveRequest ? `${selectedTask.leaveRequest.requestedDays} 天` : null) ??
(selectedTask?.attendanceCorrection ? selectedTask.attendanceCorrection.workDate : null) ??
selectedTask?.purchaseRequest?.totalAmount ??
selectedTask?.travelRequest?.estimatedAmount ??
null;
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
const isSelectedReimbursement = selectedTask?.workflowInstance?.entityType === "reimbursement";
const reimbursementDecisionReady = !isSelectedReimbursement || Boolean(
canUseRemote && reimbursementEvidence && selectedTask?.workflowInstance?.contextData.parseStatus === "ready",
);
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
const employeeProgressCards = useMemo(
() =>
@@ -666,10 +601,6 @@ export function MobileApprovalsPage() {
});
function requestMobileWorkflowAction(action: WorkflowConfirmAction) {
if (isSelectedReimbursement && !reimbursementDecisionReady) {
setError("报销审批需联网加载完整明细、票据和提交时检查后才能处理。");
return;
}
if (!canUseRemote && action === "return") {
setError("当前离线不支持退回,请联网后再处理。");
return;
@@ -689,31 +620,6 @@ export function MobileApprovalsPage() {
setConfirmAction(action);
}
function appendReturnReference(reference: string) {
if (!reference) return;
setComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim());
setShowSecondaryActions(true);
setError("");
}
async function handleOpenAttachment(attachmentId: string) {
const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId);
if (!attachment) {
toast.error("附件信息已变化,请刷新后重试");
return;
}
const reason = await requestDownloadReason(attachment);
if (reason === null) return;
openAttachmentMutation.mutate({ attachment, reason });
}
async function handleOpenAllMaterials() {
const governedAttachment = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason);
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
if (reason === null) return;
materialPreviewMutation.mutate(reason);
}
async function refreshDraftQueueCache() {
if (!ownerEmployeeId) {
return [];
@@ -737,9 +643,6 @@ export function MobileApprovalsPage() {
if (!selectedTask) {
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
}
if (selectedTask.workflowInstance?.entityType === "reimbursement") {
throw new Error("报销审批不能保存离线草稿,请联网核对完整内容后处理");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前审批任务不允许该动作");
}
@@ -782,9 +685,6 @@ export function MobileApprovalsPage() {
if (!selectedTask) {
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
}
if (selectedTask.workflowInstance?.entityType === "reimbursement" && !reimbursementDecisionReady) {
throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前审批任务不允许该动作");
}
@@ -944,14 +844,16 @@ export function MobileApprovalsPage() {
: "最近 30 天已处理审批用于复核,不支持再次审批。";
const selectedActionText = selectedTask
? selectedTaskAllowsActions
? isSelectedReimbursement && !reimbursementDecisionReady
? "报销审批需联网加载完整明细、票据和提交时检查;内容就绪前不能通过、退回、驳回或保存草稿。"
: canUseRemote
? canUseRemote
? "核对摘要和历史后,可在底部通过、驳回或退回。"
: "当前离线,仅支持保存通过或驳回草稿,联网后再同步。"
: ""
: "当前任务已处理,本页只展示业务摘要和处理历史。"
: "";
if (legacyQueryTaskId) {
return <Navigate replace state={{ from: currentMobileSource }} to={`/mobile/approvals/${encodeURIComponent(legacyQueryTaskId)}`} />;
}
return (
<>
<PageScaffold
@@ -982,9 +884,7 @@ export function MobileApprovalsPage() {
{!isOnline && canAccessRemoteApprovals ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}
{isSelectedReimbursement
? "当前报销任务只显示摘要,不能保存审批草稿;请联网核对完整内容。"
: "仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。"}
仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
</p>
) : null}
@@ -1216,7 +1116,7 @@ export function MobileApprovalsPage() {
<>
<div className="mobile-subcard bg-white text-sm" data-testid="mobile-approvals-detail-focus">
<div className="text-xs font-semibold text-zinc-500">下一步</div>
<div className="mt-1 leading-6 text-zinc-700">{selectedActionText}</div>
{selectedActionText ? <div className="mt-1 leading-6 text-zinc-700">{selectedActionText}</div> : null}
</div>
<div className="mobile-list-card text-sm">
@@ -1261,77 +1161,6 @@ export function MobileApprovalsPage() {
</div>
) : null}
</div>
{selectedTask.reimbursement ? (
<div className="mt-3 space-y-3">
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
</div>
<Button asChild className="w-full" variant="secondary">
<Link
data-testid="mobile-approval-reimbursement-detail-link"
state={{ from: currentMobileSource }}
to={`/mobile/reimbursements/${encodeURIComponent(selectedTask.reimbursement.id)}`}
>
查看完整报销单
</Link>
</Button>
{!canUseRemote ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-800">
当前只显示允许离线保存的摘要。请联网加载完整明细和票据后再处理该报销审批。
</p>
) : reimbursementDetailQuery.isLoading ? (
<p className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
正在加载完整报销内容;加载完成前所有审批动作均不可用。
</p>
) : reimbursementDetailQuery.isError ? (
<div className="space-y-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
<p>{resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")}</p>
<Button className="min-h-11" onClick={() => reimbursementDetailQuery.refetch()} type="button" variant="secondary">重试加载</Button>
</div>
) : null}
{selectedTask.workflowInstance?.contextData.parseStatus === "invalid" ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。
</p>
) : null}
{reimbursementEvidence ? (
<>
{selectedTask.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}
<ReimbursementItemEvidenceList
evidence={reimbursementEvidence}
capabilities={{
onOpenInvoice: (invoice) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment) => void handleOpenAttachment(attachment.id),
onOpenMaterialBundle: () => void handleOpenAllMaterials(),
onReferenceItem: (item) => appendReturnReference(buildReimbursementReturnReference({ item })),
onReferenceInvoice: (invoice) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
}}
/>
<div className="grid gap-2" data-testid="mobile-reimbursement-approval-checks">
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
<p className="font-medium text-zinc-900">提交时检查</p>
{selectedTask.workflowInstance?.contextData.budgetCheckStatus === "missing" ? (
<p className="mt-1 text-amber-800">该次提交未记录预算快照。</p>
) : selectedTask.workflowInstance?.contextData.budgetCheckStatus === "not_applicable" ? (
<p className="mt-1 text-zinc-600">提交时未匹配适用预算。</p>
) : selectedTask.workflowInstance?.contextData.budgetCheck ? (
<p className="mt-1">预算 {selectedTask.workflowInstance.contextData.budgetCheck.budgetCode},提交后使用率 {selectedTask.workflowInstance.contextData.budgetCheck.usagePercent}%</p>
) : null}
<p className="mt-1">费用标准提示 {selectedTask.workflowInstance?.contextData.standardViolations.length ?? 0} 条</p>
</div>
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
<p className="font-medium text-zinc-900">当前票据检查</p>
<p className="mt-1">票据 {reimbursementEvidence.currentCheck.invoiceCount} 张,尚未对应明细 {reimbursementEvidence.currentCheck.unlinkedInvoiceCount} 张;需人工核对 {reimbursementEvidence.currentCheck.manualReviewItemCount} 条,申报额超出票面 {reimbursementEvidence.currentCheck.amountExceededItemCount} 条。</p>
</div>
</div>
</>
) : null}
</div>
) : null}
{selectedTask.leaveRequest ? (
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
@@ -1393,7 +1222,6 @@ export function MobileApprovalsPage() {
<Textarea
id="mobile-approvals-comment"
data-testid="mobile-approvals-comment"
disabled={isSelectedReimbursement && !reimbursementDecisionReady}
onChange={(event) => setComment(event.target.value)}
placeholder="意见(驳回/退回必填)"
rows={3}
@@ -1402,9 +1230,7 @@ export function MobileApprovalsPage() {
{!canUseRemote ? (
<p className="text-xs text-zinc-500">
{isSelectedReimbursement
? "报销审批离线时不能保存审批草稿,请联网核对完整内容。"
: "离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。"}
离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。
</p>
) : null}
@@ -1415,7 +1241,7 @@ export function MobileApprovalsPage() {
<Button
className="min-h-11 w-full"
data-testid="mobile-approvals-return"
disabled={isActionSubmitting || !reimbursementDecisionReady || !canUseRemote || !selectedTask.allowedActions.includes("return")}
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
onClick={() => requestMobileWorkflowAction("return")}
type="button"
variant="secondary"
@@ -1425,7 +1251,7 @@ export function MobileApprovalsPage() {
<Button
className="min-h-11 w-full"
data-testid="mobile-approvals-reject"
disabled={isActionSubmitting || !reimbursementDecisionReady || !selectedTask.allowedActions.includes("reject")}
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
onClick={() => requestMobileWorkflowAction("reject")}
type="button"
variant="secondary"
@@ -1443,7 +1269,7 @@ export function MobileApprovalsPage() {
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-approve"
disabled={isActionSubmitting || !reimbursementDecisionReady || !selectedTask.allowedActions.includes("approve")}
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
onClick={() => requestMobileWorkflowAction("approve")}
type="button"
>
@@ -1648,7 +1474,6 @@ export function MobileApprovalsPage() {
}
}}
/>
{downloadReasonDialog}
</>
);
}
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
@@ -33,24 +32,21 @@ import {
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow";
import type { PrecheckResponse } from "../api/feeControl";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { hasPermission } from "../auth/permissions";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
import { isTravelReimbursement } from "../lib/reimbursementTravel";
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
import { ReimbursementMobileWorkspaceShell } from "../components/reimbursements/ReimbursementMobileWorkspaceShell";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
import { MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
import { Button } from "../components/ui/button";
import {
Dialog,
@@ -63,17 +59,17 @@ import { Input } from "../components/ui/input";
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 {
formatInvoiceDisplayTitle,
resolveInvoicePartyText,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
import { formatDisplayDateTime, formatMoney } from "../lib/displayFormat";
import { formatMoney } from "../lib/displayFormat";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout";
@@ -84,9 +80,6 @@ import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints";
import {
focusFirstInvoiceGuardIssue,
invoiceGuardInvoiceMessages,
invoiceGuardIssueCount,
invoiceGuardItemMessages,
parseReimbursementInvoiceGuardIssues,
resolveInvoiceGuardIssuesAfterLink,
type ReimbursementInvoiceGuardIssues,
@@ -160,12 +153,6 @@ function isEditableStatus(status: ReimbursementStatus | undefined) {
return status === "draft" || status === "rejected";
}
function itemLabel(item: ReimbursementItem) {
const typeName = item.expenseType?.name ?? "费用";
const desc = item.description?.trim();
return desc ? `${typeName} · ${desc}` : typeName;
}
export function MobileReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
@@ -178,7 +165,6 @@ export function MobileReimbursementDetailPage() {
const [itemError, setItemError] = useState("");
const [actionError, setActionError] = useState("");
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
@@ -194,7 +180,6 @@ export function MobileReimbursementDetailPage() {
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [materialPreviewError, setMaterialPreviewError] = useState("");
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
@@ -216,21 +201,12 @@ export function MobileReimbursementDetailPage() {
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
const canEdit = isEditableStatus(detail?.status);
const canMaintainReimbursement = Boolean(canEdit && isParticipant);
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
const canRemindPendingWorkflow = Boolean(
detail?.status === "reviewing" &&
pendingTaskId &&
isParticipant &&
detail?.pendingWorkflow?.task?.assignee_id !== me?.id,
);
const canOpenInvoiceDialog = Boolean(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice);
const canUnbindInvoices = canMaintainReimbursement;
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && isParticipant);
const canManageAttachments = canMaintainReimbursement;
const selectedInvoiceEntries = Object.entries(selectedInvoices);
const reimbursementPrecheckQuery = useQuery({
@@ -444,21 +420,6 @@ export function MobileReimbursementDetailPage() {
},
});
const remindMutation = useMutation({
mutationFn: (taskId: string) => remindWorkflowTask(taskId),
onSuccess: async () => {
await Promise.all([
invalidateDetail(),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow"] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
]);
toast.success("已催办当前审批人");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const bindInvoicesMutation = useMutation({
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
const results: Array<{ invoiceId: string; success: boolean; message?: string }> = [];
@@ -575,11 +536,6 @@ export function MobileReimbursementDetailPage() {
variables.invoiceId,
variables.linkedItemId,
));
setInvoiceItemSelections((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
return next;
});
setInvoiceItemLinkErrors((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
@@ -808,7 +764,6 @@ export function MobileReimbursementDetailPage() {
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: async (bundle) => {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
@@ -821,8 +776,7 @@ export function MobileReimbursementDetailPage() {
},
onError: (error) => {
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
setMaterialPreviewError(message);
toast.error("连续预览生成失败,可逐份打开原件");
toast.error(message);
},
});
@@ -913,14 +867,6 @@ export function MobileReimbursementDetailPage() {
});
};
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) {
return "未关联";
}
const item = detail?.items.find((candidate) => candidate.id === linkedItemId);
return item ? itemLabel(item) : "无法识别";
};
const expenseTypes = expenseTypesQuery.data?.items ?? [];
if (!reimbursementId) {
@@ -954,617 +900,87 @@ export function MobileReimbursementDetailPage() {
);
}
const displayCode = resolveReimbursementDisplayCode(detail);
const showSubmitAction = Boolean(canMaintainReimbursement && canSubmitExpense);
const showStickyActions = showSubmitAction;
const showSecondaryActions = Boolean(actionError || canDeleteDraft);
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
standardViolations: (reimbursementPrecheckQuery.data?.standardChecks ?? []).filter((item) => item.exceeded),
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
});
const workspaceModel = {
header: buildReimbursementWorkspaceHeader(detail),
evidence: workspaceEvidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
};
const workspaceCapabilities = {
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
if (sourceAttachment) void handleOpenAttachment(sourceAttachment);
},
onOpenMaterialBundle: detail.invoices.length || detail.attachments.length ? () => void handleOpenAllMaterials() : undefined,
onEditItem: canMaintainReimbursement ? (item: { id: string }) => {
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
if (sourceItem) startEditItem(sourceItem);
} : undefined,
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
linkInvoicePending: linkInvoiceItemMutation.isPending,
linkInvoiceErrors: invoiceItemLinkErrors,
onUnlinkInvoice: canUnbindInvoices ? (invoice: { id: string }) => unbindInvoiceMutation.mutate(invoice.id) : undefined,
onDeleteAttachment: canMaintainReimbursement ? (attachment: { id: string; originalName: string }) => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName }) : undefined,
};
const workspaceHeaderDetails = (
<>
{!isParticipant ? <p className="text-xs text-zinc-500">只读查看{detail.status === "draft" ? " · 等待报销人完善" : detail.status === "rejected" ? " · 等待报销人修改后重提" : ""}</p> : null}
{canMaintainReimbursement ? <ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable mobile showDescription={!isTravelReimbursement(detail)} /> : null}
<ReimbursementTravelSummary detail={detail} reimbursementId={reimbursementId} editable={canMaintainReimbursement} mobile embedded />
{detail.project ? <p className="text-sm text-zinc-600">项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
</>
);
const workspaceBeforeEvidence = (
<>
{invoiceGuardIssues ? <section className="mobile-card border-red-300 bg-red-50" role="alert" aria-live="assertive"><h2 className="text-base font-semibold text-red-800">票据检查未通过</h2><p className="mt-2 text-sm leading-6 text-red-700">请按明细和票据中的提示处理后再次提交。</p></section> : null}
{actionError ? <p className="mobile-card border-red-200 bg-red-50 text-sm text-red-700" data-testid="reimbursement-action-error" role="alert" aria-live="assertive">{actionError}</p> : null}
{itemError ? <p className="mobile-card border-red-200 bg-red-50 text-sm text-red-700" role="alert">{itemError}</p> : null}
{canMaintainReimbursement ? (
<section className="mobile-card space-y-3">
<div className="mobile-section-header"><h2 className="mobile-section-title">维护报销内容</h2></div>
<div className="grid grid-cols-2 gap-2">
<Button className="min-h-11" onClick={startCreateItem} type="button" variant="secondary">新增明细</Button>
<Button className="min-h-11" data-testid={TEST_IDS.reimbursement.invoiceAdd} onClick={() => closeInvoiceDialog(true)} type="button" variant="secondary">添加发票</Button>
<Button className="min-h-11" onClick={() => attachmentInputRef.current?.click()} type="button" variant="secondary">选择附件</Button>
<Button className="min-h-11" disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending} onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)} type="button" variant="secondary">{uploadAttachmentsMutation.isPending ? "上传中..." : selectedAttachmentFiles.length ? `上传 ${selectedAttachmentFiles.length} 个附件` : "上传附件"}</Button>
</div>
<input ref={attachmentInputRef} className="sr-only" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files=Array.from(event.target.files ?? []); if(files.length>MAX_REIMBURSEMENT_ATTACHMENT_FILES){ setSelectedAttachmentFiles([]); event.target.value=""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return;} setSelectedAttachmentFiles(files); }} />
{itemForm ? (
<div className="space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
<label className="block space-y-1 text-sm text-zinc-700"><span>费用类型</span><select className="select-std w-full" disabled={!expenseTypes.length || itemSaveMutation.isPending} value={itemForm.expenseTypeId} onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}>{!expenseTypes.length ? <option value="">暂无可用类型</option> : null}{expenseTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>日期</span><Input disabled={itemSaveMutation.isPending} type="date" value={itemForm.expenseDate} onChange={(event) => setItemForm((current) => current ? { ...current, expenseDate: event.target.value } : current)} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>金额</span><Input disabled={itemSaveMutation.isPending} inputMode="decimal" value={itemForm.amount} onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>说明</span><Textarea disabled={itemSaveMutation.isPending} rows={2} value={itemForm.description} onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)} /></label>
{shouldShowOfficeElectronicsHint(expenseTypes.find((type) => type.id === itemForm.expenseTypeId), itemForm.amount) ? <p className="text-xs text-amber-700">该金额通常应选择办公电子设备。</p> : null}
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button></div>
</div>
) : <p className="text-xs text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
</section>
) : null}
</>
);
const workspaceAfterEvidence = (
<>
{(detail.budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <section className="mobile-card space-y-3"><h2 className="text-base font-semibold text-zinc-900">需要关注</h2>{detail.budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {detail.budgetHint.budgetCode}:提交后预计使用 {detail.budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</section> : null}
<section className="mobile-card"><h2 className="mb-3 text-base font-semibold text-zinc-900">处理进度</h2><ProcessingProgress progress={detail.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="mobile-reimbursement-processing-progress" targetLinkLabel="查看所在付款批次" mobile /></section>
{canDeleteDraft ? <section className="mobile-card"><Button className="w-full" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除草稿</Button></section> : null}
</>
);
const mobileActions = showSubmitAction ? <MobileStickyActionBar testId="mobile-reimbursement-sticky-actions"><Button className="min-h-12 w-full" data-testid={TEST_IDS.reimbursement.submit} disabled={submitMutation.isPending} onClick={() => void handleSubmit()} type="button">{submitMutation.isPending ? "提交中..." : "提交审批"}</Button></MobileStickyActionBar> : null;
return (
<PageScaffold
className={cn(
"space-y-4",
showStickyActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : "pb-6",
)}
data-testid="mobile-reimbursement-detail"
>
<>
{downloadReasonDialog}
<MobileDetailHeader
title={displayCode}
subtitle="报销详情"
fallbackTo={reimbursementFallback(detail.status)}
status={
<span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
{STATUS_LABEL[detail.status]}
</span>
}
testId="mobile-reimbursement-detail-header"
/>
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-reimbursement-detail-focus">
<div className="mobile-section-header mobile-section-header--top">
<div>
<h2 className="mobile-section-title">报销概况</h2>
{!isParticipant ? (
<p className="mt-1 text-xs text-zinc-500">
只读查看
{detail.status === "draft"
? " · 等待报销人完善"
: detail.status === "rejected"
? " · 等待报销人修改后重提"
: ""}
</p>
) : null}
</div>
</div>
<div data-testid="mobile-reimbursement-summary">
<div className="text-xs text-zinc-500">合计金额</div>
<div className="mt-1 text-3xl font-semibold text-zinc-900">{formatMoney(detail.totalAmount)}</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">报销日期</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{formatDate(detail.reimbursementDate)}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">报销人</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.owner?.name ?? "-"}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">部门</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.department?.name ?? "-"}</div>
</div>
<div className="rounded-lg bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">申请人</div>
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.submitter?.name ?? "-"}</div>
</div>
</div>
<ReimbursementHeaderEditor
reimbursementId={reimbursementId}
reimbursementDate={detail.reimbursementDate}
description={detail.description}
editable={Boolean(canEdit && isParticipant)}
mobile
showDescription={!isTravelReimbursement(detail)}
/>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEdit && isParticipant)}
mobile
embedded
/>
{detail.project || detail.linkedTravelRequest ? (
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm text-zinc-600">
{detail.project ? <p>项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
{detail.linkedTravelRequest ? (
<Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}>
差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}
</Link>
) : null}
</div>
) : null}
{detail.riskFlags.length ? (
<div className="mt-4 border-t border-zinc-200 pt-4">
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
</div>
) : null}
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
<div className="mt-3">
<ProcessingProgress
progress={detail.processingProgress ?? { stages: [] }}
ariaLabel="报销处理进度"
testId="mobile-reimbursement-processing-progress"
targetLinkLabel="查看所在付款批次"
mobile
/>
</div>
</section>
{invoiceGuardIssues ? (
<section
className="mobile-card border-red-300 bg-red-50"
data-testid="mobile-reimbursement-invoice-guard-summary"
role="alert"
aria-live="assertive"
>
<h2 className="text-base font-semibold text-red-800">票据检查未通过</h2>
<p className="mt-2 text-sm leading-6 text-red-700">
共 {invoiceGuardIssueCount(invoiceGuardIssues)} 项需要处理,请按下方明细和发票提示修改。
</p>
</section>
) : null}
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
{canMaintainReimbursement ? (
<Button size="sm" variant="secondary" type="button" onClick={startCreateItem}>
新增
</Button>
) : null}
</div>
{itemError ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{itemError}</div>
) : null}
{itemForm ? (
<div className="mt-3 space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
<div className="mobile-info-grid">
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
<span>费用类型</span>
<select
className="select-std"
disabled={!expenseTypes.length || itemSaveMutation.isPending}
value={itemForm.expenseTypeId}
onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}
>
{!expenseTypes.length ? <option value="">暂无可用类型</option> : null}
{expenseTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description ? (
<span className="text-xs text-zinc-500">
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description}
</span>
) : null}
</label>
<label className="block space-y-1 text-sm text-zinc-700">
<span>日期</span>
<Input
disabled={itemSaveMutation.isPending}
type="date"
value={itemForm.expenseDate}
onChange={(event) => setItemForm((current) => current ? { ...current, expenseDate: event.target.value } : current)}
/>
</label>
<label className="block space-y-1 text-sm text-zinc-700">
<span>金额</span>
<Input
disabled={itemSaveMutation.isPending}
inputMode="decimal"
placeholder="0.00"
value={itemForm.amount}
onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)}
/>
</label>
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
<span>说明</span>
<Textarea
disabled={itemSaveMutation.isPending}
rows={2}
value={itemForm.description}
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
/>
</label>
{shouldShowOfficeElectronicsHint(
expenseTypes.find((type) => type.id === itemForm.expenseTypeId),
itemForm.amount,
) ? (
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
) : null}
</div>
<div className="mobile-two-action-grid">
<Button
type="button"
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
onClick={() => itemSaveMutation.mutate(itemForm)}
>
{itemSaveMutation.isPending ? "保存中..." : "保存"}
</Button>
<Button type="button" variant="secondary" disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)}>
取消
</Button>
</div>
</div>
) : null}
<div className="mt-3 space-y-2">
{detail.items.length ? (
detail.items.map((item) => (
<article
key={item.id}
className={cn(
"mobile-list-card",
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length && "border-red-300 bg-red-50/60",
)}
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.expenseType?.name ?? "费用"}</div>
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.expenseDate)}</div>
</div>
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatMoney(item.amount)}</div>
</div>
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
{shouldShowOfficeElectronicsHint(item.expenseType, item.amount) ? (
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
) : null}
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
<p
key={message}
className="mt-2 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
role="alert"
>
{message}
</p>
))}
{canMaintainReimbursement ? (
<div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
编辑
</Button>
<Button
size="sm"
variant="secondary"
type="button"
disabled={itemDeleteMutation.isPending}
onClick={() => setConfirmDeleteItemId(item.id)}
>
删除
</Button>
</div>
) : null}
</article>
))
) : (
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无明细
</div>
)}
</div>
<div className="mt-3 flex items-center justify-between rounded-lg bg-zinc-50 px-3 py-3 text-sm font-medium text-zinc-900">
<span>共 {detail.items.length} 条明细</span>
<span>报销合计 {formatMoney(detail.totalAmount)}</span>
</div>
<ReimbursementOperationalHints
precheck={reimbursementPrecheckQuery.data}
items={detail.items}
canAccessAssets={canAccessAssets}
mobile
/>
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-materials">
<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">
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
</p>
</div>
</div>
{detail.invoiceAmountManualReviewRequired ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
本单包含票面金额未自动读取的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
</p>
) : null}
{detail.invoices.length || (detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2">
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
<Button
className="min-h-12 w-full"
data-testid="mobile-reimbursement-materials-open-all"
type="button"
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
</Button>
</div>
) : null}
{materialPreviewError ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{materialPreviewError},可在下方逐份打开原件。
</p>
) : null}
{canOpenInvoiceDialog || canManageAttachments ? (
<div className="mobile-two-action-grid mt-3">
{canOpenInvoiceDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
size="sm"
variant="secondary"
type="button"
onClick={() => closeInvoiceDialog(true)}
>
添加发票
</Button>
) : null}
{canManageAttachments ? (
<Button
size="sm"
variant="secondary"
type="button"
onClick={() => attachmentInputRef.current?.click()}
>
添加附件
</Button>
) : null}
</div>
) : null}
{invoiceActionError ? (
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{invoiceActionError}
</p>
) : null}
{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) => {
const files = Array.from(event.target.files ?? []);
if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) {
setSelectedAttachmentFiles([]);
event.target.value = "";
toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`);
return;
}
setSelectedAttachmentFiles(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.invoices.length || (detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2">
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
{detail.invoices.map((invoice) => {
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
const guardMessages = invoiceGuardInvoiceMessages(
invoiceGuardIssues,
invoice.id,
invoice.linkedItemId,
);
return (
<article
key={invoice.id}
className={cn(
"mobile-list-card",
guardMessages.length && "border-red-300 bg-red-50/60",
)}
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
tabIndex={guardMessages.length ? -1 : undefined}
>
<button
type="button"
className="flex min-h-11 w-full items-start gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openInvoiceMutation.isPending}
aria-label={`查看发票原件:${formatInvoiceDisplayTitle(invoice)}`}
onClick={() => openInvoiceMutation.mutate(invoice.id)}
>
<span className="min-w-0 flex-1">
<span className="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
<span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
<span className="mt-1 block text-xs text-zinc-500">
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatMoney(invoice.totalAmount)].filter(Boolean).join(" · ")}
</span>
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
</span>
<span className="shrink-0 text-right">
<span className="inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{resolveInvoiceStatusLabel(invoice.status)}
</span>
<ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" />
</span>
</button>
{guardMessages.map((message) => (
<p
key={message}
className="mt-3 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
role="alert"
>
{message}
</p>
))}
{invoice.amountEvidenceStatus === "manual_review" ? (
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-5 text-amber-800">
票面金额未自动识别,需由财务查看原件核对。
</p>
) : null}
{invoice.amountEvidenceStatus === "ocr_failed" ? (
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm leading-5 text-red-700">
该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。
</p>
) : null}
{canMaintainReimbursement && !invoice.linkedItemId ? (
<div className="mt-3 space-y-2">
<label className="block text-sm font-medium text-zinc-700" htmlFor={`mobile-invoice-item-link-${invoice.id}`}>
选择报销明细
</label>
<select
id={`mobile-invoice-item-link-${invoice.id}`}
className="select-std min-h-11 w-full"
value={invoiceItemSelections[invoice.id] ?? ""}
onChange={(event) => setInvoiceItemSelections((previous) => ({
...previous,
[invoice.id]: event.target.value,
}))}
>
<option value="">请选择明细</option>
{detail.items.map((item) => (
<option key={item.id} value={item.id}>
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
</option>
))}
</select>
<Button
className="min-h-11 w-full"
type="button"
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
onClick={() => linkInvoiceItemMutation.mutate({
invoiceId: invoice.id,
linkedItemId: invoiceItemSelections[invoice.id],
})}
>
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
</Button>
{invoiceItemLinkErrors[invoice.id] ? (
<p className="text-sm leading-5 text-red-700" role="alert">
{invoiceItemLinkErrors[invoice.id]}
</p>
) : null}
</div>
) : null}
{canUnbindInvoices ? (
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
) : null}
</article>
);
})}
</div>
<div data-testid="mobile-reimbursement-attachments" className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<article key={attachment.id} className="mobile-list-card">
<button
type="button"
className="flex min-h-11 w-full items-center gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openAttachmentMutation.isPending}
aria-label={`查看补充附件:${attachment.originalName}`}
onClick={() => void handleOpenAttachment(attachment)}
>
<span className="min-w-0 flex-1">
<span className="inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600">补充附件</span>
<span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</span>
<span className="mt-1 block text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</span>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{canManageAttachments ? (
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={deleteAttachmentMutation.isPending}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName })}
>
删除
</Button>
) : null}
</article>
))}
</div>
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无报销材料
</div>
)}
</section>
{showSecondaryActions ? (
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
{actionError ? (
<div
className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
role="alert"
aria-live="assertive"
>
{actionError}
</div>
) : null}
<div className="mt-3 grid gap-2">
{canDeleteDraft ? (
<Button
className="w-full"
disabled={deleteReimbursementMutation.isPending}
type="button"
variant="destructive"
onClick={() => setConfirmDeleteReimbursement(true)}
>
删除草稿
</Button>
) : null}
</div>
</section>
) : null}
{showStickyActions ? (
<MobileStickyActionBar testId="mobile-reimbursement-sticky-actions">
{showSubmitAction ? (
<Button
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
data-testid={TEST_IDS.reimbursement.submit}
disabled={submitMutation.isPending}
type="button"
onClick={() => void handleSubmit()}
>
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
) : null}
</MobileStickyActionBar>
) : null}
<ReimbursementMobileWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} fallbackTo={reimbursementFallback(detail.status)} slots={{ status: <span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>{STATUS_LABEL[detail.status]}</span>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, mobileActions }} />
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
<DialogContent
className="overflow-y-auto p-4"
@@ -1898,7 +1314,7 @@ export function MobileReimbursementDetailPage() {
}
}}
/>
</PageScaffold>
</>
);
}
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchWorkflowTaskDetail } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { Button } from "../components/ui/button";
import { getCachedWorkflowTaskDetail, cacheWorkflowTaskDetail } from "../mobile/workflowActionQueue";
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { MobileApprovalsPage } from "./MobileApprovalsPage";
import { ReimbursementApprovalAdapter } from "./reimbursement-workspace/ReimbursementApprovalAdapter";
export function MobileWorkflowTaskRoutePage() {
const { taskId = "" } = useParams();
const { accessToken, me } = useAuth();
const queryClient = useQueryClient();
const [online, setOnline] = useState(() => typeof navigator === "undefined" || navigator.onLine);
const ownerEmployeeId = me?.id ?? null;
const canUseRemote = online && Boolean(accessToken);
useEffect(() => {
const handleOnline = () => setOnline(true);
const handleOffline = () => setOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
const detailQuery = useQuery({
enabled: Boolean(taskId) && canUseRemote,
queryKey: ["mobile", "workflow", "task-detail", taskId],
queryFn: () => fetchWorkflowTaskDetail(taskId),
});
const cachedDetailQuery = useQuery({
enabled: Boolean(taskId) && Boolean(ownerEmployeeId),
queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, taskId],
queryFn: () => getCachedWorkflowTaskDetail(taskId, ownerEmployeeId),
});
useEffect(() => {
if (!detailQuery.data || !ownerEmployeeId) return;
void cacheWorkflowTaskDetail(detailQuery.data, ownerEmployeeId).then(() =>
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, taskId] }),
);
}, [detailQuery.data, ownerEmployeeId, queryClient, taskId]);
const detail = detailQuery.data ?? cachedDetailQuery.data?.detail ?? null;
if (!taskId) return <p className="mobile-card text-sm text-red-700">缺少审批任务参数。</p>;
if (!detail && (detailQuery.isLoading || cachedDetailQuery.isLoading)) return <div className="mobile-card h-40 animate-pulse bg-zinc-100" />;
if (!detail) {
return (
<div className="mobile-card space-y-3">
<p className="text-sm text-red-700">{canUseRemote ? resolveUserErrorMessage(detailQuery.error, "审批任务加载失败,请返回列表重试。") : "当前离线且没有该任务的安全缓存,请联网后重试。"}</p>
<Button asChild variant="secondary"><a href="/mobile/approvals">返回审批列表</a></Button>
</div>
);
}
if (detail.workflowInstance?.entityType === "reimbursement") {
return <ReimbursementApprovalAdapter detail={detail} layout="mobile" online={canUseRemote} refetchTask={detailQuery.refetch} taskId={taskId} />;
}
return <MobileApprovalsPage preloadedTaskDetail={detail} />;
}
+74 -707
查看文件
@@ -2,10 +2,9 @@ import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveEr
import { useCallback, useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ChevronRight } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { fetchExpenseTypes } from "../api/expenseTypes";
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
import { fetchProjects } from "../api/projects";
import {
createReimbursementItem,
deleteReimbursementAttachment,
@@ -21,7 +20,6 @@ import {
updateReimbursementItem,
uploadReimbursementAttachments,
withdrawReimbursement,
linkReimbursementProject,
linkReimbursementInvoiceItem,
type ReimbursementItem,
} from "../api/reimbursements";
@@ -38,17 +36,16 @@ import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { hasCompleteTravelSummary, isTravelReimbursement } from "../lib/reimbursementTravel";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
import { ReimbursementDesktopWorkspaceShell } from "../components/reimbursements/ReimbursementDesktopWorkspaceShell";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
@@ -60,21 +57,12 @@ import {
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { BusinessDetailActionPanel } from "../components/patterns/BusinessDetailTemplate";
import { SectionPanel } from "../components/patterns/SectionPanel";
import {
formatInvoiceDisplayTitle,
resolveInvoicePartyText,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
@@ -94,12 +82,11 @@ import {
type ItemEditorInitialValues,
type ItemEditorSaveData,
} from "./reimbursement-detail/ReimbursementItemEditor";
import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
import {
focusFirstInvoiceGuardIssue,
invoiceGuardInvoiceMessages,
invoiceGuardItemMessages,
parseReimbursementInvoiceGuardIssues,
resolveInvoiceGuardIssuesAfterLink,
type ReimbursementInvoiceGuardIssues,
@@ -111,7 +98,6 @@ type SelectedInvoiceMap = Record<string, string>;
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
return resolveUserErrorMessage(error, fallbackMessage);
}
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const location = useLocation();
@@ -121,7 +107,6 @@ export function ReimbursementDetailPage() {
const [actionError, setActionError] = useState("");
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
@@ -139,7 +124,6 @@ export function ReimbursementDetailPage() {
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [materialPreviewError, setMaterialPreviewError] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
@@ -172,22 +156,10 @@ export function ReimbursementDetailPage() {
queryFn: () => fetchExpenseTypes({ enabled: true }),
});
const projectsQuery = useQuery({
enabled: Boolean(
detailQuery.data &&
me?.id &&
[detailQuery.data.ownerId, detailQuery.data.submitterId].includes(me.id) &&
["draft", "rejected"].includes(detailQuery.data.status),
),
queryKey: ["projects", "reimbursement-link"],
queryFn: () => fetchProjects({ mine: true }),
});
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const reimbursementPrecheckQuery = useQuery({
enabled: Boolean(
@@ -385,23 +357,6 @@ export function ReimbursementDetailPage() {
},
});
const projectLinkMutation = useMutation({
mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["projects"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("已更新关联项目");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "关联项目失败");
toast.error(message);
},
});
const bindInvoicesMutation = useMutation({
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
if (!reimbursementId) {
@@ -544,11 +499,6 @@ export function ReimbursementDetailPage() {
variables.invoiceId,
variables.linkedItemId,
));
setInvoiceItemSelections((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
return next;
});
setInvoiceItemLinkErrors((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
@@ -762,7 +712,6 @@ export function ReimbursementDetailPage() {
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: async (bundle) => {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
@@ -775,8 +724,7 @@ export function ReimbursementDetailPage() {
},
onError: (error) => {
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
setMaterialPreviewError(message);
toast.error("连续预览生成失败,可逐份打开原件");
toast.error(message);
},
});
@@ -837,10 +785,8 @@ export function ReimbursementDetailPage() {
isReimbursementParticipant;
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");
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false);
const submitInvoiceNotice = canMaintainReimbursement && requiresAnyInvoice && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
const selectedInvoiceEntries = Object.entries(selectedInvoices);
function handleInvoicePdfSelection(files: FileList | null) {
@@ -981,19 +927,6 @@ export function ReimbursementDetailPage() {
);
};
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) {
return "未关联";
}
const linkedItem = detail.items.find((item) => item.id === linkedItemId);
if (!linkedItem) {
return "无法识别";
}
return `${linkedItem.expenseType?.name ?? "未识别费用类型"} · ${linkedItem.description ?? "未填写说明"}`;
};
const itemCount = detail.items.length;
const invoiceCount = detail.invoices.length;
const hasBlockingPrecheck = blockStandardChecks.length > 0;
@@ -1001,9 +934,6 @@ export function ReimbursementDetailPage() {
const currentProcessingStage = processingProgress.stages.find((stage) =>
stage.state === "current" || stage.state === "exception",
);
const currentHandlerText = currentProcessingStage
? `${currentProcessingStage.label}${currentProcessingStage.actorName ? ` · ${currentProcessingStage.actorName}` : ""}`
: detail.status === "paid" ? "已完成" : "待处理";
const nextStepText = (() => {
if (["draft", "rejected"].includes(detail.status)) {
if (!isReimbursementParticipant) {
@@ -1031,7 +961,6 @@ export function ReimbursementDetailPage() {
}
return "查看记录";
})();
const showReviewHints = Boolean(budgetHint || detail.riskFlags.length);
const primaryAction = (() => {
if (canMaintainReimbursement) {
if (!itemCount) {
@@ -1105,636 +1034,74 @@ export function ReimbursementDetailPage() {
) : null,
];
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
standardViolations: precheckStandardChecks.filter((item) => item.exceeded),
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
});
const workspaceModel = {
header: buildReimbursementWorkspaceHeader(detail),
evidence: workspaceEvidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
};
const workspaceCapabilities = {
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
if (sourceAttachment) void handleOpenAttachment(sourceAttachment);
},
onOpenMaterialBundle: detail.invoices.length || detail.attachments.length ? () => void handleOpenAllMaterials() : undefined,
onEditItem: canMaintainReimbursement ? (item: { id: string }) => {
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
if (sourceItem) startEditItem(sourceItem);
} : undefined,
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
linkInvoicePending: linkInvoiceItemMutation.isPending,
linkInvoiceErrors: invoiceItemLinkErrors,
onUnlinkInvoice: canUnbindInvoices ? (invoice: { id: string }) => unbindInvoiceMutation.mutate(invoice.id) : undefined,
onDeleteAttachment: canMaintainReimbursement ? (attachment: { id: string; originalName: string }) =>
setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName }) : undefined,
};
const workspaceHeaderDetails = canMaintainReimbursement || isTravelReimbursement(detail) || detail.project || detail.linkedTravelRequest ? (
<SectionPanel title="补充信息">
{canMaintainReimbursement ? (
<ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable showDescription={!isTravelReimbursement(detail)} />
) : null}
<ReimbursementTravelSummary detail={detail} reimbursementId={reimbursementId} editable={canMaintainReimbursement} embedded />
{detail.project ? <p className="mt-3 text-sm text-zinc-600">关联项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
{detail.linkedTravelRequest ? <Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={"/travel-requests?tab=requests&id=" + detail.linkedTravelRequest.id}>差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}</Link> : null}
</SectionPanel>
) : null;
const workspaceBeforeEvidence = (
<>
{invoiceGuardIssues ? <div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3" role="alert" aria-live="assertive"><p className="font-medium text-red-900">票据检查未通过</p><p className="mt-1 text-sm text-red-700">请按明细和票据中的提示处理后再次提交。</p></div> : null}
{actionError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" data-testid="reimbursement-action-error" role="alert">{actionError}</p> : null}
{itemError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{itemError}</p> : null}
{canMaintainReimbursement ? (
<SectionPanel title="维护报销内容" actions={<div className="flex flex-wrap gap-2"><Button data-testid="reimbursement-item-add" disabled={itemSaveMutation.isPending} onClick={startCreateItem} type="button" variant="secondary">新增明细</Button><Button data-testid={TEST_IDS.reimbursement.invoiceAdd} disabled={!canOpenInvoiceBindDialog} onClick={() => handleInvoiceDialogOpenChange(true)} type="button" variant="secondary">添加发票</Button><label className="inline-flex min-h-11 cursor-pointer items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-700" htmlFor="reimbursement-attachment-upload">选择附件</label><input className="hidden" id="reimbursement-attachment-upload" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) { setSelectedAttachmentFiles([]); event.target.value = ""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return; } setSelectedAttachmentFiles(files); }} /><Button disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending} onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)} type="button" variant="secondary">{uploadAttachmentsMutation.isPending ? "上传中..." : selectedAttachmentFiles.length ? `上传 ${selectedAttachmentFiles.length} 个附件` : "上传附件"}</Button></div>}>
{itemEditor ? <ReimbursementItemEditor mode={itemEditor.mode} expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} isPending={itemSaveMutation.isPending} /> : <p className="text-sm text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
</SectionPanel>
) : null}
</>
);
const workspaceAfterEvidence = (
<>
{(budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <SectionPanel title="需要关注">{budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</SectionPanel> : null}
<SectionPanel title="处理进度"><ProcessingProgress progress={processingProgress} ariaLabel="报销处理进度" testId="reimbursement-workflow-progress" targetLinkLabel="查看所在付款批次" /></SectionPanel>
</>
);
const workspaceActionPanel = (
<BusinessDetailActionPanel status={<StatusBadge status={detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <Button asChild variant="secondary"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></Button> : null}{canDeleteReimbursement ? <Button data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除草稿</Button> : null}</div> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""} submitDisabled={blockStandardChecks.length > 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} />
</BusinessDetailActionPanel>
);
return (
<PageScaffold>
<>
{downloadReasonDialog}
<div>
<Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost">
<Link replace to={backSource}>
<ArrowLeft aria-hidden="true" className="size-4" />
{backLabel}
</Link>
</Button>
</div>
<EntityOverviewHero
title={`报销详情 ${displayCode}`}
meta={(
<>
<StatusBadge status={detail.status} />
{!isReimbursementParticipant ? (
<span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600">只读查看</span>
) : null}
</>
)}
/>
<BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
<BusinessDetailMain>
<SectionPanel title="报销概况">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
<div>
<div className="text-xs font-semibold text-zinc-500">下一步</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{nextStepText}</div>
</div>
<div data-testid="reimbursement-business-summary">
<div className="detail-fact-grid xl:grid-cols-3" data-testid="reimbursement-summary">
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销总额</div>
<div className="detail-fact-card__value">{formatMoney(detail.totalAmount)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">当前处理</div>
<div className="detail-fact-card__value">{currentHandlerText}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">部门</div>
<div className="detail-fact-card__value">
{detail.department?.name ?? "-"}
</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销日期</div>
<div className="detail-fact-card__value">{detail.reimbursementDate ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">提交人 / 报销人</div>
<div className="detail-fact-card__value">{detail.submitter?.name ?? "-"} / {detail.owner?.name ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">关联项目</div>
<div className="detail-fact-card__value">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
</div>
</div>
</div>
<ReimbursementHeaderEditor
reimbursementId={reimbursementId}
reimbursementDate={detail.reimbursementDate}
description={detail.description}
editable={Boolean(canEditItems && isReimbursementParticipant)}
showDescription={!isTravelReimbursement(detail)}
/>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEditItems && isReimbursementParticipant)}
embedded
/>
{canMaintainReimbursement || detail.project || detail.linkedTravelRequest ? (
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm" data-testid="reimbursement-related-records">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="font-medium text-zinc-900">关联事项</span>
{detail.linkedTravelRequest ? (
<Link to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`} className="text-primary hover:underline">
差旅申请 {detail.linkedTravelRequest.requestNo ?? "未编号"}
</Link>
) : null}
</div>
{canMaintainReimbursement ? (
<select
className="select-std mt-3 max-w-xl"
value={detail.projectId ?? ""}
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
disabled={projectLinkMutation.isPending}
aria-label="关联项目"
>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>{item.projectNo} · {item.name}</option>
))}
</select>
) : detail.project ? (
<Link className="mt-2 inline-block text-primary hover:underline" to={`/projects/${detail.project.id}`}>
项目 {detail.project.projectNo} · {detail.project.name}
</Link>
) : null}
</div>
) : null}
</div>
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
data-testid="reimbursement-action-error"
role="alert"
aria-live="assertive"
>
{actionError}
</p>
) : null}
{submitInvoiceNotice ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
{submitInvoiceNotice}
</p>
) : null}
{hasBlockingPrecheck ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
请修改超标明细后提交。
</p>
) : null}
{showReviewHints ? (
<div className="space-y-3" data-testid="reimbursement-review-hints">
{budgetHint ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%
{budgetHint.exceedsThreshold ? ",已达到预警阈值。" : "。"}
</p>
) : null}
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
</div>
) : null}
</div>
</SectionPanel>
<SectionPanel title="处理进度">
<ProcessingProgress
progress={processingProgress}
ariaLabel="报销处理进度"
testId="reimbursement-workflow-progress"
targetLinkLabel="查看所在付款批次"
/>
</SectionPanel>
<SectionPanel title="报销明细">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
{canMaintainReimbursement ? (
<div className="flex justify-end">
<Button
data-testid="reimbursement-item-add"
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
onClick={startCreateItem}
type="button"
variant="secondary"
>
新增明细
</Button>
</div>
) : null}
{itemError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
data-testid="reimbursement-item-error"
>
{itemError}
</p>
) : null}
{reimbursementPrecheckQuery.isLoading && canMaintainReimbursement ? (
<p className="text-sm text-zinc-500">正在刷新费用标准校验结果...</p>
) : null}
<div className="ops-table-focus overflow-x-auto border border-zinc-200">
<Table data-testid="reimbursement-items-table">
<TableHeader>
<tr>
<TableHead>费用类型</TableHead>
<TableHead>发生日期</TableHead>
<TableHead>金额</TableHead>
<TableHead>说明</TableHead>
<TableHead className="w-44">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{detail.items.length ? (
detail.items.map((item) => (
<TableRow
data-testid="reimbursement-item-row"
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
key={item.id}
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
className={
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length
? "bg-red-50/70"
: blockStandardChecks.some((check) => check.itemId === item.id)
? "bg-red-50/70"
: warnStandardChecks.some((check) => check.itemId === item.id)
? "bg-amber-50/70"
: ""
}
>
<TableCell>
<div className="font-medium text-zinc-900">
{item.expenseType?.name ?? "未识别费用类型"}
</div>
</TableCell>
<TableCell>{item.expenseDate ?? "-"}</TableCell>
<TableCell>
<div>{formatMoney(item.amount)}</div>
{precheckStandardChecks
.filter((check) => check.itemId === item.id)
.map((check) => (
<div
key={check.standardId}
className={`mt-1 text-xs ${check.exceeded ? (check.controlMode === "block" ? "text-red-600" : "text-amber-600") : "text-zinc-500"}`}
>
标准 {formatMoney(check.maxAmount)}/{check.perUnit}
{check.exceeded ? `,超出 ${formatMoney(check.exceededAmount)}` : ""}
</div>
))}
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
<div
key={message}
className="mt-2 rounded-md border border-red-200 bg-red-50 px-2 py-1.5 text-xs leading-5 text-red-700"
role="alert"
>
{message}
</div>
))}
</TableCell>
<TableCell>
<div>{item.description ?? "-"}</div>
{shouldShowOfficeElectronicsHint(item.expenseType, item.amount) ? (
<div className="mt-1 text-xs text-amber-600">
该金额通常应选择办公电子设备。
</div>
) : null}
{precheckStandardChecks
.filter((check) => check.itemId === item.id && check.exceeded)
.map((check) => (
<div
key={`${check.standardId}-warning`}
className={`mt-1 text-xs ${check.controlMode === "block" ? "text-red-600" : "text-amber-600"}`}
>
{check.standardName}:当前金额超出费用标准。
</div>
))}
</TableCell>
<TableCell>
{canMaintainReimbursement ? (
<div className="flex min-w-36 items-center gap-2">
<Button
data-testid="reimbursement-item-edit"
className="min-w-16"
size="sm"
type="button"
variant="secondary"
onClick={() => startEditItem(item)}
>
编辑
</Button>
<Button
data-testid="reimbursement-item-delete"
className="min-w-16"
size="sm"
type="button"
variant="secondary"
disabled={itemDeleteMutation.isPending}
onClick={() => setConfirmDeleteItemId(item.id)}
>
删除
</Button>
</div>
) : null}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={5}>
暂无明细
</TableCell>
</TableRow>
)}
</TableBody>
<tfoot className="border-t border-zinc-200 bg-zinc-50 font-medium text-zinc-900">
<tr>
<TableCell colSpan={2}>共 {detail.items.length} 条明细</TableCell>
<TableCell>{formatMoney(detail.totalAmount)}</TableCell>
<TableCell colSpan={2}>报销合计</TableCell>
</tr>
</tfoot>
</Table>
</div>
<ReimbursementOperationalHints
precheck={reimbursementPrecheckQuery.data}
items={detail.items}
canAccessAssets={canAccessAssets}
/>
{itemEditor ? (
<ReimbursementItemEditor
mode={itemEditor.mode}
expenseTypes={expenseTypes}
expenseTypesLoading={expenseTypesQuery.isLoading}
expenseTypesError={expenseTypesQuery.isError}
departmentId={detail.departmentId}
initialValues={editorInitialValues}
onSave={handleItemSave}
onCancel={handleItemCancel}
isPending={itemSaveMutation.isPending}
/>
) : null}
</div>
</div>
</SectionPanel>
<SectionPanel title="报销材料">
<div className="space-y-4 text-sm" data-testid="reimbursement-materials">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-zinc-600">
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
<span className="mt-1 block text-xs text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</span>
</p>
<div className="flex flex-wrap gap-2">
{detail.invoices.length || (detail.attachments ?? []).length ? (
<Button
data-testid="reimbursement-materials-open-all"
type="button"
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
</Button>
) : null}
{canOpenInvoiceBindDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
type="button"
variant="secondary"
onClick={() => handleInvoiceDialogOpenChange(true)}
>
添加发票
</Button>
) : null}
</div>
</div>
{materialPreviewError ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{materialPreviewError},可在下方逐份打开原件。
</p>
) : null}
{invoiceActionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
data-testid={TEST_IDS.reimbursement.invoiceActionError}
>
{invoiceActionError}
</p>
) : null}
{detail.invoiceAmountManualReviewRequired ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
本单包含票面金额未自动读取的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
</p>
) : null}
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
{detail.invoices.map((invoice) => {
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
const guardMessages = invoiceGuardInvoiceMessages(
invoiceGuardIssues,
invoice.id,
invoice.linkedItemId,
);
return (
<div
className={`detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center ${guardMessages.length ? "border-red-300 bg-red-50/60" : ""}`}
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
key={invoice.id}
tabIndex={guardMessages.length ? -1 : undefined}
>
<button
type="button"
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openInvoiceMutation.isPending}
aria-label={`查看发票原件:${formatInvoiceDisplayTitle(invoice)}`}
onClick={() => openInvoiceMutation.mutate(invoice.id)}
>
<span className="inline-flex shrink-0 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
<span className="mt-1 block text-xs text-zinc-500">
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDisplayDate(invoice.invoiceDate) : "", formatMoney(invoice.totalAmount)].filter(Boolean).join(" · ")}
</span>
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
</span>
<span className="badge-neutral shrink-0">{resolveInvoiceStatusLabel(invoice.status)}</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{(guardMessages.length
|| invoice.amountEvidenceStatus === "manual_review"
|| invoice.amountEvidenceStatus === "ocr_failed"
|| canUnbindInvoices) ? (
<div className="w-full space-y-2 md:w-72">
{guardMessages.map((message) => (
<p
key={message}
className="rounded-md border border-red-200 bg-white px-3 py-2 text-xs leading-5 text-red-700"
role="alert"
>
{message}
</p>
))}
{invoice.amountEvidenceStatus === "manual_review" ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
票面金额未自动识别,需由财务查看原件核对。
</p>
) : null}
{invoice.amountEvidenceStatus === "ocr_failed" ? (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs leading-5 text-red-700">
该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。
</p>
) : null}
{canMaintainReimbursement && !invoice.linkedItemId ? (
<div className="space-y-2">
<label className="block text-xs font-medium text-zinc-700" htmlFor={`invoice-item-link-${invoice.id}`}>
选择报销明细
</label>
<select
id={`invoice-item-link-${invoice.id}`}
className="select-std w-full"
value={invoiceItemSelections[invoice.id] ?? ""}
onChange={(event) => setInvoiceItemSelections((previous) => ({
...previous,
[invoice.id]: event.target.value,
}))}
>
<option value="">请选择明细</option>
{detail.items.map((item) => (
<option key={item.id} value={item.id}>
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
</option>
))}
</select>
<Button
className="w-full"
type="button"
size="sm"
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
onClick={() => linkInvoiceItemMutation.mutate({
invoiceId: invoice.id,
linkedItemId: invoiceItemSelections[invoice.id],
})}
>
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
</Button>
{invoiceItemLinkErrors[invoice.id] ? (
<p className="text-xs leading-5 text-red-700" role="alert">
{invoiceItemLinkErrors[invoice.id]}
</p>
) : null}
</div>
) : null}
{canUnbindInvoices ? (
<Button
className="w-full"
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
) : null}
</div>
) : null}
</div>
);
})}
</div>
<div className="space-y-4" data-testid="reimbursement-attachments">
{canMaintainReimbursement ? (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-zinc-500">支持 PDF/JPG/PNG/WebP,单次最多 5 个。</p>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<label
htmlFor="reimbursement-attachment-upload"
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
>
{selectedAttachmentFiles.length
? selectedAttachmentFiles.length === 1
? selectedAttachmentFiles[0].name
: `已选择 ${selectedAttachmentFiles.length} 个文件`
: "选择附件"}
</label>
<input
id="reimbursement-attachment-upload"
className="hidden"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
multiple
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) {
setSelectedAttachmentFiles([]);
event.target.value = "";
toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`);
return;
}
setSelectedAttachmentFiles(files);
}}
/>
<Button
type="button"
variant="secondary"
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
>
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
</Button>
</div>
</div>
) : null}
{(detail.attachments ?? []).length ? (
<div className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
<button
type="button"
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openAttachmentMutation.isPending}
aria-label={`查看补充附件:${attachment.originalName}`}
onClick={() => void handleOpenAttachment(attachment)}
>
<span className="inline-flex shrink-0 rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600">补充附件</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-zinc-900">{attachment.originalName}</span>
<span className="mt-1 block text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</span>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{canMaintainReimbursement ? (
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleteAttachmentMutation.isPending}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
>
删除
</Button>
) : null}
</div>
))}
</div>
) : detail.invoices.length ? null : (
<p className="text-sm text-zinc-500">暂无报销材料</p>
)}
</div>
</div>
</SectionPanel>
</BusinessDetailMain>
<BusinessDetailAside>
<BusinessDetailActionPanel
status={<StatusBadge status={detail.status} />}
primaryAction={primaryAction}
secondaryActions={secondaryActions}
moreActions={showSecondaryDetail ? (
<div className="grid gap-2" data-testid="reimbursement-secondary-detail">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null}
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
) : null}
testId="reimbursement-action-panel"
>
<ReimbursementActions
status={detail.status}
showSubmitAction={false}
showWithdrawAction={isReimbursementParticipant}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
submitDisabled={blockStandardChecks.length > 0}
submitHint=""
submitNotice=""
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
/>
</BusinessDetailActionPanel>
</BusinessDetailAside>
</BusinessDetailWorkspace>
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel }} />
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
<DialogContent
className="max-h-[85vh] max-w-3xl overflow-y-auto"
@@ -2005,6 +1372,6 @@ export function ReimbursementDetailPage() {
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason: deleteReason });
}}
/>
</PageScaffold>
</>
);
}
+67 -771
查看文件
@@ -1,79 +1,38 @@
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import {
approveWorkflowTask,
fetchWorkflowApprovers,
fetchWorkflowTaskDetail,
rejectWorkflowTask,
returnWorkflowTask,
transferWorkflowTask,
} from "../api/workflow";
import { toast } from "sonner";
import { fetchInvoiceSignedUrl } from "../api/invoicePool";
import {
downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
} from "../api/reimbursements";
import { isApiError } from "../api/types";
import type { WorkflowTaskDetail } from "../api/workflow";
import { approveWorkflowTask, fetchWorkflowApprovers, rejectWorkflowTask, returnWorkflowTask, transferWorkflowTask } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList";
import { StatusBadge } from "../components/common/StatusBadge";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import { BusinessDetailActionPanel, BusinessDetailAside, BusinessDetailMain, BusinessDetailWorkspace } from "../components/patterns/BusinessDetailTemplate";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { StatStrip } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { StatStrip } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
import { approvalRoleLabel, resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import {
buildReimbursementApprovalEvidence,
buildReimbursementReturnReference,
} from "../lib/reimbursementApprovalProjection";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import {
buildPcDetailSource,
resolvePcDetailSource,
type PcDetailNavigationState,
} from "../lib/pcDetailNavigation";
import { buildPcDetailSource, resolvePcDetailSource, type PcDetailNavigationState } from "../lib/pcDetailNavigation";
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
type Props = { detail: WorkflowTaskDetail; refetchTask: () => Promise<unknown>; taskId: string };
type SecondaryWorkflowAction = "reject" | "return" | "transfer";
function entityTitle(entityType: string | undefined) {
if (entityType === "leave") {
return "请假申请";
}
if (entityType === "reimbursement") {
return "报销单";
}
if (entityType === "attendance_correction") {
return "考勤修正";
}
if (entityType === "purchase_request") {
return "采购申请";
}
if (entityType === "travel_request") {
return "差旅申请";
}
if (entityType === "leave") return "请假申请";
if (entityType === "attendance_correction") return "考勤修正";
if (entityType === "purchase_request") return "采购申请";
if (entityType === "travel_request") return "差旅申请";
return entityType || "业务单据";
}
@@ -85,48 +44,36 @@ function correctionKindLabel(kind: string | undefined | null) {
}
function workflowInstanceStatusLabel(status: string | undefined | null) {
if (!status) {
return "-";
}
const labels: Record<string, string> = {
pending: "审批中",
active: "审批中",
approved: "已通过",
rejected: "已驳回",
terminated: "已终止",
cancelled: "已取消",
completed: "已完成",
};
return labels[status] ?? status;
const labels: Record<string, string> = { pending: "审批中", active: "审批中", approved: "已通过", rejected: "已驳回", terminated: "已终止", cancelled: "已取消", completed: "已完成" };
return status ? labels[status] ?? status : "-";
}
function DetailField({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="detail-fact-card">
<dt className="detail-fact-card__label">{label}</dt>
<dd className="detail-fact-card__value">{value || "-"}</dd>
</div>
);
return <div className="detail-fact-card"><dt className="detail-fact-card__label">{label}</dt><dd className="detail-fact-card__value">{value || "-"}</dd></div>;
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
type SecondaryWorkflowAction = "reject" | "return" | "transfer";
function BusinessFacts({ detail }: { detail: WorkflowTaskDetail }) {
return (
<dl className="detail-fact-grid" data-testid="workflow-task-summary">
{detail.leaveRequest ? <><DetailField label="假期类型" value={detail.leaveRequest.leaveType?.name ?? "-"} /><DetailField label="申请人" value={detail.leaveRequest.owner?.name ?? "-"} /><DetailField label="起止日期" value={`${detail.leaveRequest.startDate ?? "-"} → ${detail.leaveRequest.endDate ?? "-"}`} /><DetailField label="天数" value={`${detail.leaveRequest.requestedDays} 天`} /><DetailField label="说明" value={detail.leaveRequest.reason ?? "-"} /><DetailField label="附件" value={detail.leaveRequest.attachment?.originalName ?? "未上传"} /></> : null}
{detail.attendanceCorrection ? <><DetailField label="修正类型" value={correctionKindLabel(detail.attendanceCorrection.kind)} /><DetailField label="申请人" value={detail.attendanceCorrection.owner?.name ?? "-"} /><DetailField label="日期" value={detail.attendanceCorrection.workDate ?? "-"} /><DetailField label="时间" value={detail.attendanceCorrection.missedPunchAt ?? `${detail.attendanceCorrection.startAt ?? "-"} → ${detail.attendanceCorrection.endAt ?? "-"}`} /><DetailField label="地点" value={detail.attendanceCorrection.locationSummary ?? "-"} /><DetailField label="补偿口径" value={detail.attendanceCorrection.compensationPolicy ?? "-"} /><DetailField label="说明" value={detail.attendanceCorrection.reason ?? "-"} /></> : null}
{detail.purchaseRequest ? <><DetailField label="采购单号" value={resolvePurchaseDisplayCode(detail.purchaseRequest)} /><DetailField label="标题" value={detail.purchaseRequest.title} /><DetailField label="申请人" value={detail.purchaseRequest.applicant?.name ?? "-"} /><DetailField label="供应商" value={detail.purchaseRequest.supplier?.name ?? "-"} /><DetailField label="公司" value={detail.purchaseRequest.legalEntity?.name ?? "-"} /><DetailField label="成本中心" value={detail.purchaseRequest.costCenter?.name ?? "-"} /><DetailField label="金额" value={`${detail.purchaseRequest.totalAmount ?? "-"} ${detail.purchaseRequest.currencyCode}`} /><DetailField label="预计到货" value={detail.purchaseRequest.expectedDeliveryDate ?? "-"} /><DetailField label="说明" value={detail.purchaseRequest.reason ?? "-"} /></> : null}
{detail.travelRequest ? <><DetailField label="差旅单号" value={detail.travelRequest.requestNo ?? "暂未生成"} /><DetailField label="申请人" value={detail.travelRequest.applicant?.name ?? "-"} /><DetailField label="出差类型" value={detail.travelRequest.tripType === "international" ? "国际" : "国内"} /><DetailField label="公司" value={detail.travelRequest.legalEntity?.name ?? "-"} /><DetailField label="成本中心" value={detail.travelRequest.costCenter?.name ?? "-"} /><DetailField label="起止日期" value={`${detail.travelRequest.startDate ?? "-"} → ${detail.travelRequest.endDate ?? "-"}`} /><DetailField label="预估金额" value={detail.travelRequest.estimatedAmount ?? "-"} /><DetailField label="出差目的" value={detail.travelRequest.purpose || "-"} /></> : null}
</dl>
);
}
export function WorkflowTaskDetailPage() {
const { taskId = "" } = useParams();
export function WorkflowTaskDetailPage({ detail, refetchTask, taskId }: Props) {
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { permissions } = useAuth();
const [rejectComment, setRejectComment] = useState("请补充必要说明后重提");
const [returnComment, setReturnComment] = useState("请补充必要信息并修正后重新提交");
const [transferReason, setTransferReason] = useState("当前事项需转交对应审批人处理,请尽快处理。");
const [rejectComment, setRejectComment] = useState("");
const [returnComment, setReturnComment] = useState("");
const [transferReason, setTransferReason] = useState("");
const [selectedApprover, setSelectedApprover] = useState("");
const [actionError, setActionError] = useState("");
const [activeSecondaryAction, setActiveSecondaryAction] = useState<SecondaryWorkflowAction | null>(null);
@@ -135,705 +82,54 @@ export function WorkflowTaskDetailPage() {
const currentTaskSource = buildPcDetailSource(location.pathname, location.search);
const navigationState = location.state as PcDetailNavigationState | null;
const backSource = resolvePcDetailSource(navigationState?.from, currentTaskSource) ?? "/workflow/tasks";
const detailQuery = useQuery({
enabled: Boolean(taskId),
queryKey: ["workflow", "task", taskId],
queryFn: () => fetchWorkflowTaskDetail(taskId),
});
const reimbursementId = detailQuery.data?.reimbursement?.id ?? "";
const reimbursementDetailQuery = useQuery({
enabled: Boolean(reimbursementId),
queryKey: ["reimbursements", reimbursementId],
queryFn: () => fetchReimbursementDetail(reimbursementId),
});
const reimbursementSnapshot = detailQuery.data?.workflowInstance?.contextData ?? null;
const reimbursementEvidence = useMemo(() => {
if (!reimbursementDetailQuery.data || !reimbursementSnapshot) return null;
return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, reimbursementSnapshot);
}, [reimbursementDetailQuery.data, reimbursementSnapshot]);
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openInvoiceMutation = useMutation({
mutationFn: async (invoiceId: string) => {
const result = await fetchInvoiceSignedUrl(invoiceId);
const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
if (!openedInWecom) openFileInBrowser(result.signedUrl);
},
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开发票失败,请重试")),
});
const openAttachmentMutation = useMutation({
mutationFn: async ({ attachment, reason }: {
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
reason?: string;
}) => {
if (isWecomWebView()) {
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
return null;
}
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
},
onSuccess: (result) => {
if (!result) return;
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开附件失败,请重试")),
});
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl),
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开完整材料失败,可逐份查看原件")),
});
const approverQuery = useQuery({
enabled: hasPermission(permissions, "expense.transfer"),
queryKey: ["workflow", "approvers"],
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 50 }),
});
const allowedActions = useMemo(() => {
return new Set(detailQuery.data?.allowedActions ?? []);
}, [detailQuery.data?.allowedActions]);
const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]);
const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer");
const approverQuery = useQuery({ enabled: canTransfer, queryKey: ["workflow", "approvers"], queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 50 }) });
const mutation = useMutation({
mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => {
if (!taskId) {
throw new Error("missing taskId");
}
if (
detailQuery.data?.workflowInstance?.entityType === "reimbursement" &&
(!reimbursementEvidence || detailQuery.data.workflowInstance.contextData.parseStatus !== "ready")
) {
throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
}
if (action === "approve") {
return approveWorkflowTask(taskId);
}
if (action === "reject") {
return rejectWorkflowTask(taskId, rejectComment.trim());
}
if (action === "return") {
return returnWorkflowTask(taskId, returnComment.trim());
}
if (!selectedApprover) {
throw new Error("请选择转审目标");
}
if (action === "approve") return approveWorkflowTask(taskId);
if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim());
if (action === "return") return returnWorkflowTask(taskId, returnComment.trim());
if (!selectedApprover) throw new Error("请选择转审目标");
return transferWorkflowTask(taskId, selectedApprover, transferReason.trim());
},
onSuccess: async (_data, action) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
]);
setActionError("");
setActiveSecondaryAction(null);
setTransferConfirmOpen(false);
const messages: Record<string, string> = { approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" };
toast.success(messages[action] ?? "操作成功");
await Promise.all([queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), refetchTask()]);
setActionError(""); setActiveSecondaryAction(null); setConfirmAction(null); setTransferConfirmOpen(false);
toast.success({ approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" }[action]);
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) {
void Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
]);
}
const msg = resolveUserErrorMessage(error, "动作执行失败,请稍后重试");
setActionError(msg);
toast.error(msg);
if (isAlreadyProcessedTaskError(error)) void refetchTask();
const message = resolveUserErrorMessage(error, "动作执行失败,请稍后重试"); setActionError(message); toast.error(message);
},
});
function requestAction(action: WorkflowConfirmAction) {
const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : "";
if ((action === "reject" || action === "return") && !comment.trim()) { setActionError(`${action === "reject" ? "驳回" : "退回"}必须填写意见`); return; }
setActionError(""); setConfirmAction(action);
}
const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
function requestWorkflowAction(action: WorkflowConfirmAction) {
if (
detailQuery.data?.workflowInstance?.entityType === "reimbursement" &&
(!reimbursementEvidence || detailQuery.data.workflowInstance.contextData.parseStatus !== "ready")
) {
setActionError("完整报销内容或提交时检查尚未就绪,暂时不能处理;请重试加载后再决定。");
return;
}
if (action === "reject" && rejectComment.trim().length < 1) {
setActionError("驳回必须填写意见");
return;
}
if (action === "return" && returnComment.trim().length < 1) {
setActionError("退回必须填写意见");
return;
}
setActionError("");
setConfirmAction(action);
}
function requestWorkflowTransfer() {
if (!selectedApprover) {
setActionError("请选择转审目标");
return;
}
if (transferReason.trim().length < 10) {
setActionError("转审理由至少 10 个字");
return;
}
setActionError("");
setTransferConfirmOpen(true);
}
function appendReturnReference(reference: string) {
if (!reference) return;
setReturnComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim());
setActiveSecondaryAction("return");
setActionError("");
}
async function handleOpenAttachment(attachmentId: string) {
const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId);
if (!attachment) {
toast.error("附件信息已变化,请刷新后重试");
return;
}
const reason = await requestDownloadReason(attachment);
if (reason === null) return;
openAttachmentMutation.mutate({ attachment, reason });
}
async function handleOpenAllMaterials() {
const governedAttachment = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason);
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
if (reason === null) return;
materialPreviewMutation.mutate(reason);
}
if (!taskId) {
return (
<div className="card-std p-5">
<p className="text-sm text-red-600">缺少 taskId 参数。</p>
</div>
);
}
if (shouldPreferMobileApprovalExperience()) {
return <Navigate replace to={buildMobileApprovalsPath(taskId)} />;
}
if (detailQuery.isLoading) {
return <CardSkeleton lines={8} />;
}
if (detailQuery.isError || !detailQuery.data) {
const message = resolveUserErrorMessage(detailQuery.error, "加载失败,请返回列表重试");
return (
<div className="card-std space-y-3 p-5">
<p className="text-sm text-red-600">{message}</p>
<Button
variant="secondary"
onClick={() => navigate(backSource, { replace: true })}
type="button"
>
返回审批列表
</Button>
</div>
);
}
const detail = detailQuery.data;
const isReimbursementTask = detail.workflowInstance?.entityType === "reimbursement";
const reimbursementDecisionReady = !isReimbursementTask || Boolean(
reimbursementEvidence && detail.workflowInstance?.contextData.parseStatus === "ready",
);
const canApprove = allowedActions.has("approve");
const canReturn = allowedActions.has("return");
const canReject = allowedActions.has("reject");
const canTransfer = allowedActions.has("transfer");
const submissionConcernCount = isReimbursementTask
? (detail.workflowInstance?.contextData.standardViolations.length ?? 0) +
(detail.workflowInstance?.contextData.budgetCheck?.exceededThreshold ? 1 : 0)
: 0;
const currentEvidenceConcernCount = reimbursementEvidence
? reimbursementEvidence.currentCheck.unlinkedInvoiceCount +
reimbursementEvidence.currentCheck.missingInvoiceItemCount +
reimbursementEvidence.currentCheck.amountUnavailableItemCount +
reimbursementEvidence.currentCheck.manualReviewItemCount +
reimbursementEvidence.currentCheck.amountExceededItemCount
: 0;
const transferDisabledReason = !selectedApprover
? "请先选择目标审批人。"
: transferReason.trim().length < 10
? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。`
: "转审理由已满足要求。";
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
const actionDisabled = mutation.isPending;
return (
<>
<PageScaffold>
<EntityOverviewHero
title="审批详情"
meta={(
<>
<StatusBadge status={detail.task.status} />
<span className="info-chip">业务状态:{businessStatusLabel}</span>
<span className="info-chip">类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
<span className="info-chip">节点:{detail.task.stepName}</span>
</>
)}
actions={(
<Button asChild variant="secondary" data-testid="workflow-task-back">
<Link replace to={backSource}>
返回审批列表
</Link>
</Button>
)}
/>
<BusinessDetailWorkspace>
<BusinessDetailMain>
<SectionPanel title="当前状态与审批对象">
<div className="space-y-4" data-testid="workflow-task-overview">
<StatStrip columns={4} className="ops-status-strip">
<MetricCard title="当前节点" value={<span className="text-base">{detail.task.stepName}</span>} />
<MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
{allowedActionLabels ? (
<MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels}</span>} />
) : null}
<MetricCard title="创建时间" value={<span className="text-base">{formatDisplayDateTime(detail.task.createdAt)}</span>} />
</StatStrip>
<dl className="detail-fact-grid" data-testid="workflow-task-summary">
{detail.reimbursement ? (
<>
<DetailField label="报销单号" value={detail.reimbursement.formNumber ?? "-"} />
<DetailField label="报销金额" value={detail.reimbursement.totalAmount ?? "-"} />
<DetailField label="提交人" value={detail.reimbursement.submitter?.name ?? "-"} />
<DetailField label="报销人" value={detail.reimbursement.owner?.name ?? "-"} />
<DetailField label="报销日期" value={detail.reimbursement.reimbursementDate ?? "-"} />
<DetailField label="报销事由" value={detail.reimbursement.description ?? "-"} />
</>
) : null}
{detail.leaveRequest ? (
<>
<DetailField label="假期类型" value={detail.leaveRequest.leaveType?.name ?? "-"} />
<DetailField label="申请人" value={detail.leaveRequest.owner?.name ?? "-"} />
<DetailField
label="时间段"
value={
<>
{detail.leaveRequest.startDate ?? "-"} {detail.leaveRequest.startSession === "am" ? "上午" : "下午"}
{" → "}
{detail.leaveRequest.endDate ?? "-"} {detail.leaveRequest.endSession === "am" ? "上午" : "下午"}
</>
}
/>
<DetailField label="请假天数" value={detail.leaveRequest.requestedDays} />
<DetailField label="说明" value={detail.leaveRequest.reason ?? "-"} />
<DetailField label="附件" value={detail.leaveRequest.attachment?.originalName ?? "-"} />
</>
) : null}
{detail.attendanceCorrection ? (
<>
<DetailField label="修正类型" value={correctionKindLabel(detail.attendanceCorrection.kind)} />
<DetailField label="申请人" value={detail.attendanceCorrection.owner?.name ?? "-"} />
<DetailField label="考勤日期" value={detail.attendanceCorrection.workDate ?? "-"} />
<DetailField label="状态" value={workflowInstanceStatusLabel(detail.attendanceCorrection.status)} />
<DetailField
label="时间"
value={
detail.attendanceCorrection.kind === "missed_punch"
? formatDisplayDateTime(detail.attendanceCorrection.missedPunchAt)
: `${formatDisplayDateTime(detail.attendanceCorrection.startAt)} → ${formatDisplayDateTime(detail.attendanceCorrection.endAt)}`
}
/>
<DetailField label="外出地点" value={detail.attendanceCorrection.locationSummary ?? "-"} />
<DetailField label="补偿口径" value={detail.attendanceCorrection.compensationPolicy ?? "-"} />
<DetailField label="说明" value={detail.attendanceCorrection.reason ?? "-"} />
</>
) : null}
{detail.purchaseRequest ? (
<>
<DetailField label="采购单号" value={resolvePurchaseDisplayCode(detail.purchaseRequest)} />
<DetailField label="标题" value={detail.purchaseRequest.title || "-"} />
<DetailField label="申请人" value={detail.purchaseRequest.applicant?.name ?? "-"} />
<DetailField label="供应商" value={detail.purchaseRequest.supplier?.name ?? "-"} />
<DetailField label="公司" value={detail.purchaseRequest.legalEntity?.name ?? "-"} />
<DetailField label="成本中心" value={detail.purchaseRequest.costCenter?.name ?? "-"} />
<DetailField label="单据分类" value={detail.purchaseRequest.documentCategory?.name ?? "-"} />
<DetailField label="金额" value={`${detail.purchaseRequest.totalAmount ?? "-"} ${detail.purchaseRequest.currencyCode}`} />
<DetailField label="预计到货" value={detail.purchaseRequest.expectedDeliveryDate ?? "-"} />
<DetailField label="说明" value={detail.purchaseRequest.reason ?? "-"} />
</>
) : null}
{detail.travelRequest ? (
<>
<DetailField label="差旅单号" value={detail.travelRequest.requestNo ?? "暂未生成"} />
<DetailField label="申请人" value={detail.travelRequest.applicant?.name ?? "-"} />
<DetailField label="出差类型" value={detail.travelRequest.tripType === "international" ? "国际" : "国内"} />
<DetailField label="公司" value={detail.travelRequest.legalEntity?.name ?? "-"} />
<DetailField label="成本中心" value={detail.travelRequest.costCenter?.name ?? "-"} />
<DetailField label="起止日期" value={`${detail.travelRequest.startDate ?? "-"} → ${detail.travelRequest.endDate ?? "-"}`} />
<DetailField label="预估金额" value={detail.travelRequest.estimatedAmount ?? "-"} />
<DetailField label="出差目的" value={detail.travelRequest.purpose || "-"} />
<DetailField
label="行程段"
value={
detail.travelRequest.segments.length
? detail.travelRequest.segments
.map((item) => `${item.fromCity}→${item.toCity}(${item.segmentRole})`)
.join(";")
: "-"
}
/>
</>
) : null}
</dl>
{detail.reimbursement ? (
<Button asChild variant="secondary">
<Link
data-testid="workflow-task-reimbursement-detail-link"
state={{ from: currentTaskSource }}
to={`/reimbursements/${encodeURIComponent(detail.reimbursement.id)}`}
>
打开完整原始表单
</Link>
</Button>
) : null}
</div>
</SectionPanel>
{isReimbursementTask ? (
<SectionPanel title="报销明细与材料">
{detail.task.status !== "pending" ? (
<p className="mb-4 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">
当前展示报销单的最新版本,可能包含申请人在退回后所做的修改;审批意见和提交时检查仍保留历史语义。
</p>
) : null}
{reimbursementDetailQuery.isLoading ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-4 text-sm text-zinc-600">
正在加载完整报销内容和票据,请稍候;加载完成前不能作出审批决定。
</div>
) : null}
{reimbursementDetailQuery.isError ? (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700" role="alert">
<p>{resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")}</p>
<Button onClick={() => reimbursementDetailQuery.refetch()} type="button" variant="secondary">重试加载</Button>
</div>
) : null}
{detail.workflowInstance?.contextData.parseStatus === "invalid" ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700" role="alert">
提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对该次提交记录。
</p>
) : null}
{reimbursementEvidence ? (
<ReimbursementItemEvidenceList
evidence={reimbursementEvidence}
capabilities={{
onOpenInvoice: (invoice) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment) => void handleOpenAttachment(attachment.id),
onOpenMaterialBundle: () => void handleOpenAllMaterials(),
onReferenceItem: (item) => appendReturnReference(buildReimbursementReturnReference({ item })),
onReferenceInvoice: (invoice) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
}}
/>
) : null}
</SectionPanel>
) : null}
{isReimbursementTask ? (
<SectionPanel title="提交时检查与当前票据检查">
<div className="grid gap-3 md:grid-cols-2" data-testid="reimbursement-approval-checks">
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-700">
<h3 className="font-medium text-zinc-900">提交时检查</h3>
{detail.workflowInstance?.contextData.budgetCheckStatus === "missing" ? (
<p className="mt-2 text-amber-800">该次提交未记录预算快照。</p>
) : detail.workflowInstance?.contextData.budgetCheckStatus === "not_applicable" ? (
<p className="mt-2 text-zinc-600">提交时未匹配适用预算。</p>
) : detail.workflowInstance?.contextData.budgetCheck ? (
<div className="mt-2 space-y-1">
<p>预算:{detail.workflowInstance.contextData.budgetCheck.budgetCode}</p>
<p>提交后使用率:{detail.workflowInstance.contextData.budgetCheck.usagePercent}%</p>
<p>{detail.workflowInstance.contextData.budgetCheck.exceededBudget ? "提交时已超出预算" : "提交时未超出预算"}</p>
</div>
) : null}
<p className="mt-2">费用标准提示:{detail.workflowInstance?.contextData.standardViolations.length ?? 0} 条</p>
</div>
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-700">
<h3 className="font-medium text-zinc-900">当前票据检查</h3>
{reimbursementEvidence ? (
<div className="mt-2 space-y-1">
<p>票据 {reimbursementEvidence.currentCheck.invoiceCount} 张,尚未对应明细 {reimbursementEvidence.currentCheck.unlinkedInvoiceCount} 张</p>
<p>缺少票据的明细 {reimbursementEvidence.currentCheck.missingInvoiceItemCount} 条</p>
<p>金额未能读取 {reimbursementEvidence.currentCheck.amountUnavailableItemCount} 条,需人工核对 {reimbursementEvidence.currentCheck.manualReviewItemCount} 条,申报额超出票面 {reimbursementEvidence.currentCheck.amountExceededItemCount} 条</p>
</div>
) : <p className="mt-2 text-red-700">当前票据检查尚未就绪。</p>}
</div>
</div>
</SectionPanel>
) : null}
<SectionPanel title="处理历史">
<div className="detail-timeline">
{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.actorId}
</div>
<div className="text-zinc-600">{item.comment || "-"}</div>
<div className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div>
</div>
))
) : (
<p className="text-sm text-zinc-500">暂无历史记录</p>
)}
</div>
</SectionPanel>
</BusinessDetailMain>
<BusinessDetailAside>
<BusinessDetailActionPanel
title="处理"
className="workflow-action-panel"
primaryAction={canApprove ? (
<Button
data-testid="workflow-task-approve"
disabled={mutation.isPending || !reimbursementDecisionReady}
onClick={() => requestWorkflowAction("approve")}
type="button"
>
通过
</Button>
) : null}
secondaryActions={canReturn || canReject ? (
<>
{canReturn ? (
<Button
data-testid="workflow-task-return-toggle"
variant="secondary"
disabled={mutation.isPending || !reimbursementDecisionReady}
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")}
type="button"
>
退回
</Button>
) : null}
{canReject ? (
<Button
data-testid="workflow-task-reject-toggle"
variant="secondary"
disabled={mutation.isPending || !reimbursementDecisionReady}
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")}
type="button"
>
驳回
</Button>
) : null}
</>
) : null}
moreActions={canTransfer ? (
<details className="workflow-action-more">
<summary>更多</summary>
<button
data-testid="workflow-task-transfer-menu-item"
disabled={!reimbursementDecisionReady}
type="button"
onClick={() => setActiveSecondaryAction("transfer")}
>
转审
</button>
</details>
) : null}
summary={(
<>
<div>节点:{detail.task.stepName}</div>
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
<div>当前审批角色:{approvalRoleLabel(detail.task.assigneeRole)}</div>
<div>创建时间:{formatDisplayDateTime(detail.task.createdAt)}</div>
<div>完成时间:{formatDisplayDateTime(detail.task.completedAt)}</div>
<div>发起时间:{formatDisplayDateTime(detail.workflowInstance?.startedAt)}</div>
{isReimbursementTask ? (
<>
<div>报销合计:{detail.reimbursement?.totalAmount ?? "-"}</div>
<div>
内容核验:{reimbursementDecisionReady ? "完整内容已就绪" : "尚未就绪,所有审批动作已停用"}
</div>
{reimbursementEvidence ? (
<div>
检查摘要:提交时提示 {submissionConcernCount} 项;当前票据需关注 {currentEvidenceConcernCount} 项
</div>
) : null}
</>
) : null}
</>
)}
testId="workflow-task-action-panel"
>
{canReturn || canReject ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600" data-testid="workflow-action-consequence-help">
<div><span className="font-medium text-zinc-800">退回:</span>原单回到申请人修改,保留单号和历史,修改后可重新提交。</div>
<div><span className="font-medium text-zinc-800">驳回:</span>本次流程终止或进入驳回态,申请人通常需要复制或重新发起。</div>
</div>
) : null}
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
data-testid="workflow-task-action-error"
>
{actionError}
</p>
) : null}
{activeSecondaryAction === "reject" ? (
<div className="detail-action-card space-y-2">
<Label className="text-sm font-medium text-zinc-800" htmlFor="workflow-task-reject-comment">驳回意见</Label>
<p className="text-xs leading-5 text-zinc-500">驳回会终止本次流程或进入驳回态,申请人通常需要重新发起。</p>
<Textarea
id="workflow-task-reject-comment"
data-testid="workflow-task-reject-comment"
onChange={(event) => setRejectComment(event.target.value)}
rows={3}
value={rejectComment}
/>
<Button
data-testid="workflow-task-reject"
variant="secondary"
disabled={!allowedActions.has("reject") || mutation.isPending || !reimbursementDecisionReady || rejectComment.trim().length < 1}
onClick={() => requestWorkflowAction("reject")}
type="button"
>
确认驳回
</Button>
</div>
) : null}
{activeSecondaryAction === "return" ? (
<div className="detail-action-card space-y-2">
<Label className="text-sm font-medium text-zinc-800" htmlFor="workflow-task-return-comment">退回意见</Label>
<p className="text-xs leading-5 text-zinc-500">退回后原单可修改并重新提交,单号和审批历史会保留。</p>
<Textarea
id="workflow-task-return-comment"
data-testid="workflow-task-return-comment"
onChange={(event) => setReturnComment(event.target.value)}
rows={3}
value={returnComment}
/>
<Button
data-testid="workflow-task-return"
variant="secondary"
disabled={!allowedActions.has("return") || mutation.isPending || !reimbursementDecisionReady || returnComment.trim().length < 1}
onClick={() => requestWorkflowAction("return")}
type="button"
>
确认退回
</Button>
</div>
) : null}
{activeSecondaryAction === "transfer" && canTransfer ? (
<div className="detail-action-card space-y-2">
<p className="text-sm font-medium text-zinc-800">转审</p>
<Label className="text-xs text-zinc-600" htmlFor="workflow-task-transfer-approver">目标审批人</Label>
<select
id="workflow-task-transfer-approver"
className="select-std"
data-testid="workflow-task-transfer-approver"
onChange={(event) => setSelectedApprover(event.target.value)}
value={selectedApprover}
>
<option value="">请选择目标审批人</option>
{(approverQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.employeeNo})
</option>
))}
</select>
<Label className="text-xs text-zinc-600" htmlFor="workflow-task-transfer-reason">转审理由</Label>
<Textarea
id="workflow-task-transfer-reason"
aria-describedby="workflow-task-transfer-help"
data-testid="workflow-task-transfer-reason"
onChange={(event) => setTransferReason(event.target.value)}
rows={3}
value={transferReason}
/>
<p
id="workflow-task-transfer-help"
className={transferReason.trim().length >= 10 && selectedApprover ? "text-xs text-emerald-700" : "text-xs text-amber-700"}
data-testid="workflow-task-transfer-disabled-reason"
>
{transferDisabledReason}
</p>
<Button
aria-describedby="workflow-task-transfer-help"
data-testid="workflow-task-transfer"
variant="secondary"
disabled={
!allowedActions.has("transfer") ||
mutation.isPending ||
!reimbursementDecisionReady ||
!selectedApprover ||
transferReason.trim().length < 10
}
onClick={requestWorkflowTransfer}
type="button"
>
确认转审
</Button>
</div>
) : null}
</BusinessDetailActionPanel>
</BusinessDetailAside>
</BusinessDetailWorkspace>
</PageScaffold>
<ConfirmDialog
open={Boolean(confirmCopy)}
onOpenChange={(open) => {
if (!open) {
setConfirmAction(null);
}
}}
title={confirmCopy?.title ?? ""}
description={confirmCopy?.description}
confirmLabel={confirmCopy?.confirmLabel}
variant={confirmCopy?.variant}
testId="workflow-action-confirm-dialog"
confirmTestId="workflow-action-confirm-submit"
cancelTestId="workflow-action-confirm-cancel"
onConfirm={async () => {
if (!confirmAction) {
return;
}
await mutation.mutateAsync(confirmAction);
}}
/>
<ConfirmDialog
open={transferConfirmOpen}
onOpenChange={setTransferConfirmOpen}
title="确认转审审批?"
description={`事项:${entityTitle(detail.workflowInstance?.entityType)} · ${detail.task.stepName}。转审后当前任务会交给目标审批人处理,系统会写入审批历史。`}
confirmLabel="确认转审"
variant="default"
testId="workflow-transfer-confirm-dialog"
confirmTestId="workflow-transfer-confirm-submit"
cancelTestId="workflow-transfer-confirm-cancel"
onConfirm={async () => {
await mutation.mutateAsync("transfer");
}}
/>
{downloadReasonDialog}
<PageScaffold>
<EntityOverviewHero title="审批详情" meta={<><StatusBadge status={detail.task.status} /><span className="info-chip">业务状态:{businessStatusLabel}</span><span className="info-chip">类型:{entityTitle(detail.workflowInstance?.entityType)}</span><span className="info-chip">节点:{detail.task.stepName}</span></>} actions={<Button asChild variant="secondary" data-testid="workflow-task-back"><Link replace to={backSource}>返回审批列表</Link></Button>} />
<BusinessDetailWorkspace>
<BusinessDetailMain>
<SectionPanel title="当前状态与审批对象"><div className="space-y-4" data-testid="workflow-task-overview"><StatStrip columns={4} className="ops-status-strip"><MetricCard title="当前节点" value={<span className="text-base">{detail.task.stepName}</span>} /><MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />{allowedActionLabels ? <MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels}</span>} /> : null}<MetricCard title="创建时间" value={<span className="text-base">{formatDisplayDateTime(detail.task.createdAt)}</span>} /></StatStrip><BusinessFacts detail={detail} /></div></SectionPanel>
<SectionPanel title="处理历史"><div className="detail-timeline">{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><div className="text-zinc-600">{item.comment || "-"}</div><div className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div></div>) : <p className="text-sm text-zinc-500">暂无历史记录</p>}</div></SectionPanel>
</BusinessDetailMain>
<BusinessDetailAside>
<BusinessDetailActionPanel title="处理" primaryAction={allowedActions.has("approve") ? <Button data-testid="workflow-task-approve" disabled={actionDisabled} onClick={() => requestAction("approve")} type="button">通过</Button> : null} secondaryActions={<>{allowedActions.has("return") ? <Button data-testid="workflow-task-return-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回</Button> : null}{allowedActions.has("reject") ? <Button data-testid="workflow-task-reject-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")} type="button" variant="secondary">驳回</Button> : null}</>} moreActions={canTransfer ? <Button className="w-full" data-testid="workflow-task-transfer-menu-item" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="ghost">转审</Button> : null} summary={<><div>节点:{detail.task.stepName}</div><div>实例状态:{businessStatusLabel}</div><div>当前审批角色:{approvalRoleLabel(detail.task.assigneeRole)}</div></>} testId="workflow-task-action-panel">
<div className="space-y-3">{actionError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{actionError}</p> : null}{activeSecondaryAction === "return" ? <div className="space-y-2"><Label htmlFor="workflow-task-return-comment">退回意见</Label><p className="text-xs text-zinc-500" data-testid="workflow-action-consequence-help">退回后原单可修改并重新提交,单号和历史会保留。</p><Textarea data-testid="workflow-task-return-comment" id="workflow-task-return-comment" value={returnComment} onChange={(event) => setReturnComment(event.target.value)} /><Button className="w-full" data-testid="workflow-task-return" disabled={!returnComment.trim() || actionDisabled} onClick={() => requestAction("return")} type="button" variant="secondary">确认退回</Button></div> : null}{activeSecondaryAction === "reject" ? <div className="space-y-2"><Label htmlFor="workflow-task-reject-comment">驳回意见</Label><p className="text-xs text-zinc-500" data-testid="workflow-action-consequence-help">驳回会终止本次流程,申请人通常需要重新发起。</p><Textarea data-testid="workflow-task-reject-comment" id="workflow-task-reject-comment" value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} /><Button className="w-full" data-testid="workflow-task-reject" disabled={!rejectComment.trim() || actionDisabled} onClick={() => requestAction("reject")} type="button" variant="secondary">确认驳回</Button></div> : null}{activeSecondaryAction === "transfer" ? <div className="space-y-2"><Label htmlFor="workflow-task-transfer-approver">目标审批人</Label><select className="select-std" data-testid="workflow-task-transfer-approver" id="workflow-task-transfer-approver" value={selectedApprover} onChange={(event) => setSelectedApprover(event.target.value)}><option value="">请选择目标审批人</option>{(approverQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name} ({item.employeeNo})</option>)}</select><Label htmlFor="workflow-task-transfer-reason">转审理由</Label><Textarea data-testid="workflow-task-transfer-reason" id="workflow-task-transfer-reason" value={transferReason} onChange={(event) => setTransferReason(event.target.value)} /><p className="text-xs text-zinc-500" data-testid="workflow-task-transfer-disabled-reason">{!selectedApprover ? "请先选择目标审批人。" : transferReason.trim().length < 10 ? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。` : "转审理由已满足要求。"}</p><Button className="w-full" data-testid="workflow-task-transfer" disabled={!selectedApprover || transferReason.trim().length < 10 || actionDisabled} onClick={() => setTransferConfirmOpen(true)} type="button" variant="secondary">确认转审</Button></div> : null}</div>
</BusinessDetailActionPanel>
</BusinessDetailAside>
</BusinessDetailWorkspace>
</PageScaffold>
<ConfirmDialog open={Boolean(confirmCopy)} onOpenChange={(open) => { if (!open) setConfirmAction(null); }} title={confirmCopy?.title ?? ""} description={confirmCopy?.description} confirmLabel={confirmCopy?.confirmLabel} variant={confirmCopy?.variant} testId="workflow-action-confirm-dialog" confirmTestId="workflow-action-confirm-submit" cancelTestId="workflow-action-confirm-cancel" onConfirm={async () => { if (confirmAction) await mutation.mutateAsync(confirmAction); }} />
<ConfirmDialog open={transferConfirmOpen} onOpenChange={setTransferConfirmOpen} title="确认转审审批?" description={`事项:${entityTitle(detail.workflowInstance?.entityType)} · ${detail.task.stepName}。转审后将交给目标审批人继续处理。`} confirmLabel="确认转审" testId="workflow-transfer-confirm-dialog" onConfirm={async () => { await mutation.mutateAsync("transfer"); }} />
</>
);
}
@@ -0,0 +1,35 @@
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchWorkflowTaskDetail } from "../api/workflow";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { Button } from "../components/ui/button";
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
import { ReimbursementApprovalAdapter } from "./reimbursement-workspace/ReimbursementApprovalAdapter";
import { WorkflowTaskDetailPage } from "./WorkflowTaskDetailPage";
export function WorkflowTaskRoutePage() {
const { taskId = "" } = useParams();
const navigate = useNavigate();
const detailQuery = useQuery({
enabled: Boolean(taskId),
queryKey: ["workflow", "task", taskId],
queryFn: () => fetchWorkflowTaskDetail(taskId),
});
if (shouldPreferMobileApprovalExperience()) return <Navigate replace to={buildMobileApprovalsPath(taskId)} />;
if (!taskId) return <p className="card-std p-5 text-sm text-red-700">缺少审批任务参数。</p>;
if (detailQuery.isLoading) return <CardSkeleton lines={8} />;
if (detailQuery.isError || !detailQuery.data) {
return (
<div className="card-std space-y-3 p-5">
<p className="text-sm text-red-700">{resolveUserErrorMessage(detailQuery.error, "加载失败,请返回列表重试")}</p>
<Button type="button" variant="secondary" onClick={() => navigate("/workflow/tasks", { replace: true })}>返回审批列表</Button>
</div>
);
}
if (detailQuery.data.workflowInstance?.entityType === "reimbursement") {
return <ReimbursementApprovalAdapter detail={detailQuery.data} layout="desktop" refetchTask={detailQuery.refetch} taskId={taskId} />;
}
return <WorkflowTaskDetailPage detail={detailQuery.data} refetchTask={detailQuery.refetch} taskId={taskId} />;
}
@@ -0,0 +1,446 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation } from "react-router-dom";
import { toast } from "sonner";
import type { WorkflowTaskDetail } from "../../api/workflow";
import {
approveWorkflowTask,
fetchWorkflowApprovers,
rejectWorkflowTask,
returnWorkflowTask,
transferWorkflowTask,
} from "../../api/workflow";
import {
downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
} from "../../api/reimbursements";
import { fetchInvoiceSignedUrl } from "../../api/invoicePool";
import { isApiError } from "../../api/types";
import { useAuth } from "../../auth/AuthContext";
import { hasPermission } from "../../auth/permissions";
import { ConfirmDialog } from "../../components/common/ConfirmDialog";
import { StatusBadge } from "../../components/common/StatusBadge";
import { useAttachmentDownloadReasonDialog } from "../../components/common/useAttachmentDownloadReasonDialog";
import { ReimbursementDesktopWorkspaceShell } from "../../components/reimbursements/ReimbursementDesktopWorkspaceShell";
import { ReimbursementMobileWorkspaceShell } from "../../components/reimbursements/ReimbursementMobileWorkspaceShell";
import { BusinessDetailActionPanel } from "../../components/patterns/BusinessDetailTemplate";
import { MobileStickyActionBar } from "../../components/patterns/MobileStickyActionBar";
import { SectionPanel } from "../../components/patterns/SectionPanel";
import { Button } from "../../components/ui/button";
import { Label } from "../../components/ui/label";
import { Textarea } from "../../components/ui/textarea";
import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDisplay";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance";
import { formatDisplayDateTime } from "../../lib/displayFormat";
import {
buildReimbursementApprovalEvidence,
buildReimbursementReturnReference,
} from "../../lib/reimbursementApprovalProjection";
import {
buildReimbursementWorkspaceHeader,
buildReimbursementWorkspaceIssueAnchors,
} from "../../lib/reimbursementWorkspace";
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { isWecomWebView } from "../../lib/runtimeContext";
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../../lib/workflowActionConfirm";
import { openFileInBrowser, previewFileInWecom } from "../../lib/wecomFilePreview";
type SecondaryWorkflowAction = "reject" | "return" | "transfer";
type Props = {
detail: WorkflowTaskDetail;
layout: "desktop" | "mobile";
refetchTask: () => Promise<unknown>;
taskId: string;
online?: boolean;
};
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
function workflowInstanceStatusLabel(status: string | undefined | null) {
const labels: Record<string, string> = {
pending: "审批中",
active: "审批中",
reviewing: "审批中",
approved: "已通过",
rejected: "已驳回",
terminated: "已终止",
cancelled: "已取消",
completed: "已完成",
};
return status ? labels[status] ?? status : "-";
}
function workflowTaskStatusLabel(status: string) {
const labels: Record<string, string> = {
pending: "待处理",
completed: "已处理",
rejected: "已驳回",
cancelled: "已取消",
};
return labels[status] ?? "未知状态";
}
function resolveApprovalBackTarget(value: unknown, layout: "desktop" | "mobile") {
const fallback = layout === "mobile" ? "/mobile/approvals" : "/workflow/tasks";
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.includes("\\") || value.includes("#")) {
return fallback;
}
if (layout === "mobile") {
return value === "/mobile" || value.startsWith("/mobile/approvals?") ? value : fallback;
}
return value === "/workflow/tasks" || value.startsWith("/workflow/tasks?") ? value : fallback;
}
export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, taskId, online = true }: Props) {
const queryClient = useQueryClient();
const location = useLocation();
const { permissions } = useAuth();
const backTarget = resolveApprovalBackTarget((location.state as { from?: unknown } | null)?.from, layout);
const reimbursementId = detail.reimbursement?.id ?? "";
const [rejectComment, setRejectComment] = useState("");
const [returnComment, setReturnComment] = useState("");
const [transferReason, setTransferReason] = useState("");
const [selectedApprover, setSelectedApprover] = useState("");
const [actionError, setActionError] = useState("");
const [activeSecondaryAction, setActiveSecondaryAction] = useState<SecondaryWorkflowAction | null>(null);
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
const [transferConfirmOpen, setTransferConfirmOpen] = useState(false);
const reimbursementDetailQuery = useQuery({
enabled: Boolean(reimbursementId) && online,
queryKey: ["reimbursements", reimbursementId],
queryFn: () => fetchReimbursementDetail(reimbursementId),
});
const snapshot = detail.workflowInstance?.contextData ?? null;
const evidence = useMemo(() => {
if (!reimbursementDetailQuery.data || !snapshot) return null;
return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, snapshot);
}, [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",
}),
};
}, [evidence, reimbursementDetailQuery.data, snapshot?.budgetCheckStatus, snapshot?.parseStatus]);
const decisionReady = Boolean(online && model && snapshot?.parseStatus === "ready");
const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]);
const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer");
const approverQuery = useQuery({
enabled: canTransfer && online,
queryKey: ["workflow", "approvers"],
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 50 }),
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openInvoiceMutation = useMutation({
mutationFn: async (invoiceId: string) => {
const result = await fetchInvoiceSignedUrl(invoiceId);
const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
if (!openedInWecom) openFileInBrowser(result.signedUrl);
},
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开发票失败,请重试")),
});
const openAttachmentMutation = useMutation({
mutationFn: async ({ attachment, reason }: {
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
reason?: string;
}) => {
if (isWecomWebView()) {
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
return null;
}
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
},
onSuccess: (result) => {
if (!result) return;
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开附件失败,请重试")),
});
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl),
onError: (error) => toast.error(resolveUserErrorMessage(error, "打开完整材料失败,可逐份查看原件")),
});
const actionMutation = useMutation({
mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => {
if (!decisionReady) throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
if (action === "approve") return approveWorkflowTask(taskId);
if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim());
if (action === "return") return returnWorkflowTask(taskId, returnComment.trim());
if (!selectedApprover) throw new Error("请选择转审目标");
return transferWorkflowTask(taskId, selectedApprover, transferReason.trim());
},
onSuccess: async (_data, action) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
refetchTask(),
]);
setActionError("");
setActiveSecondaryAction(null);
setConfirmAction(null);
setTransferConfirmOpen(false);
toast.success({ approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" }[action]);
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) void refetchTask();
const message = resolveUserErrorMessage(error, "动作执行失败,请稍后重试");
setActionError(message);
toast.error(message);
},
});
function requestAction(action: WorkflowConfirmAction) {
if (!decisionReady) {
setActionError(online ? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。" : "报销审批需联网核对完整报销内容后才能处理。");
return;
}
const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : "";
if ((action === "reject" || action === "return") && !comment.trim()) {
setActionError(`${action === "reject" ? "驳回" : "退回"}必须填写意见`);
return;
}
setActionError("");
setConfirmAction(action);
}
function appendReturnReference(reference: string) {
if (!reference) return;
setReturnComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim());
setActiveSecondaryAction("return");
}
async function openAttachment(attachmentId: string) {
const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId);
if (!attachment) return toast.error("附件信息已变化,请刷新后重试");
const reason = await requestDownloadReason(attachment);
if (reason !== null) openAttachmentMutation.mutate({ attachment, reason });
}
async function openAllMaterials() {
const governed = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason);
const reason = governed ? await requestDownloadReason(governed) : undefined;
if (reason !== null) materialPreviewMutation.mutate(reason);
}
if (!model) {
const message = !online
? "报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。"
: snapshot?.parseStatus === "invalid"
? "提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。"
: reimbursementDetailQuery.isError
? resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")
: "正在加载完整报销内容和票据;加载完成前不能作出审批决定。";
const disabledActions = (
<div className="grid grid-cols-2 gap-2" data-testid="reimbursement-approval-fail-closed-actions">
<Button data-testid={layout === "mobile" ? "mobile-approvals-approve" : "workflow-task-approve"} disabled type="button">通过</Button>
<Button data-testid={layout === "mobile" ? "mobile-approvals-return" : "workflow-task-return-toggle"} disabled type="button" variant="secondary">退回</Button>
<Button data-testid={layout === "mobile" ? "mobile-approvals-reject" : "workflow-task-reject-toggle"} disabled type="button" variant="secondary">驳回</Button>
<Button data-testid={layout === "mobile" ? "mobile-approvals-transfer" : "workflow-task-transfer-menu-item"} disabled type="button" variant="secondary">转审</Button>
</div>
);
return (
<div className={layout === "mobile" ? "mobile-card space-y-3" : "card-std mx-auto max-w-3xl space-y-3 p-5"} role="alert">
<p className="text-sm text-zinc-700">{message}</p>
{disabledActions}
{online && reimbursementDetailQuery.isError ? <Button onClick={() => reimbursementDetailQuery.refetch()} type="button" variant="secondary">重试加载</Button> : null}
<Button asChild type="button" variant="secondary"><Link to={layout === "mobile" ? "/mobile/approvals" : "/workflow/tasks"}>返回审批列表</Link></Button>
</div>
);
}
const confirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
const actionDisabled = actionMutation.isPending || !decisionReady;
const issueAnchors = model.issueAnchors.length ? (
<div className="space-y-2" data-testid="reimbursement-workspace-issue-anchors">
<p className="text-sm font-medium text-zinc-900">需要关注</p>
{model.issueAnchors.map((issue) => (
<a className="block text-sm text-zinc-700 underline-offset-4 hover:underline" href={`#${issue.id}`} key={`${issue.id}-${issue.label}`}>{issue.label}</a>
))}
</div>
) : null;
const statusSummary = (
<>
<div>节点:{detail.task.stepName}</div>
<div>状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
<div>审批角色:{approvalRoleLabel(detail.task.assigneeRole)}</div>
<div>{decisionReady ? "完整内容已就绪" : "内容未就绪,审批动作已停用"}</div>
</>
);
const offlineNotice = !online ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900" role="alert">
报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。
</p>
) : null;
const commentEditor = (
<>
{actionError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{actionError}</p> : null}
{activeSecondaryAction === "return" ? (
<div className="space-y-2">
<Label htmlFor={`${layout}-reimbursement-return-comment`}>退回意见</Label>
<p className="text-xs text-zinc-500">退回后原单可修改并重新提交,单号和审批历史会保留。</p>
<Textarea data-testid={layout === "desktop" ? "workflow-task-return-comment" : "mobile-approvals-comment"} id={`${layout}-reimbursement-return-comment`} value={returnComment} onChange={(event) => setReturnComment(event.target.value)} />
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-return" : "mobile-approvals-return-submit"} disabled={actionDisabled || !returnComment.trim()} onClick={() => requestAction("return")} type="button" variant="secondary">确认退回</Button>
</div>
) : null}
{activeSecondaryAction === "reject" ? (
<div className="space-y-2">
<Label htmlFor={`${layout}-reimbursement-reject-comment`}>驳回意见</Label>
<p className="text-xs text-zinc-500">驳回会终止本次流程,申请人通常需要重新发起。</p>
<Textarea data-testid={layout === "desktop" ? "workflow-task-reject-comment" : "mobile-approvals-comment"} id={`${layout}-reimbursement-reject-comment`} value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} />
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-reject" : "mobile-approvals-reject-submit"} disabled={actionDisabled || !rejectComment.trim()} onClick={() => requestAction("reject")} type="button" variant="secondary">确认驳回</Button>
</div>
) : null}
{activeSecondaryAction === "transfer" ? (
<div className="space-y-2">
<Label htmlFor={`${layout}-reimbursement-transfer-approver`}>目标审批人</Label>
<select className="select-std min-h-11" data-testid={layout === "desktop" ? "workflow-task-transfer-approver" : "mobile-approvals-transfer-to"} id={`${layout}-reimbursement-transfer-approver`} value={selectedApprover} onChange={(event) => setSelectedApprover(event.target.value)}>
<option value="">请选择目标审批人</option>
{(approverQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name} ({item.employeeNo})</option>)}
</select>
<Label htmlFor={`${layout}-reimbursement-transfer-reason`}>转审理由</Label>
<Textarea data-testid={layout === "desktop" ? "workflow-task-transfer-reason" : "mobile-approvals-transfer-reason"} id={`${layout}-reimbursement-transfer-reason`} value={transferReason} onChange={(event) => setTransferReason(event.target.value)} />
<p className="text-xs text-zinc-500" data-testid={layout === "desktop" ? "workflow-task-transfer-disabled-reason" : "mobile-approvals-transfer-disabled-reason"}>{!selectedApprover ? "请先选择目标审批人。" : transferReason.trim().length < 10 ? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。` : "转审理由已满足要求。"}</p>
<Button className="min-h-11 w-full" data-testid={layout === "desktop" ? "workflow-task-transfer" : "mobile-approvals-transfer-submit"} disabled={actionDisabled || !selectedApprover || transferReason.trim().length < 10} onClick={() => setTransferConfirmOpen(true)} type="button" variant="secondary">确认转审</Button>
</div>
) : null}
</>
);
const timeline = (
<SectionPanel title="处理历史">
<div className="detail-timeline">
{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>
<div className="text-zinc-600">{item.comment || "-"}</div>
<div className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div>
</div>
)) : <p className="text-sm text-zinc-500">暂无历史记录</p>}
</div>
</SectionPanel>
);
const hasMaterials = model.evidence.items.some((item) => item.invoices.length > 0)
|| model.evidence.unlinkedInvoices.length > 0
|| model.evidence.attachments.length > 0;
const capabilities = {
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => void openAttachment(attachment.id),
onOpenMaterialBundle: hasMaterials ? () => void openAllMaterials() : undefined,
onReferenceItem: (item: Parameters<typeof buildReimbursementReturnReference>[0]["item"]) => appendReturnReference(buildReimbursementReturnReference({ item })),
onReferenceInvoice: (invoice: Parameters<typeof buildReimbursementReturnReference>[0]["invoice"]) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
};
const dialogs = (
<>
<ConfirmDialog
open={Boolean(confirmCopy)}
onOpenChange={(open) => { if (!open) setConfirmAction(null); }}
title={confirmCopy?.title ?? ""}
description={confirmCopy?.description}
confirmLabel={confirmCopy?.confirmLabel}
variant={confirmCopy?.variant}
testId="workflow-action-confirm-dialog"
confirmTestId="workflow-action-confirm-submit"
cancelTestId="workflow-action-confirm-cancel"
onConfirm={async () => { if (confirmAction) await actionMutation.mutateAsync(confirmAction); }}
/>
<ConfirmDialog
open={transferConfirmOpen}
onOpenChange={setTransferConfirmOpen}
title="确认转审审批?"
description={`报销单 ${model.header.displayCode} 将交给所选审批人继续处理。`}
confirmLabel="确认转审"
testId="workflow-transfer-confirm-dialog"
onConfirm={async () => { await actionMutation.mutateAsync("transfer"); }}
/>
{downloadReasonDialog}
</>
);
if (layout === "mobile") {
const mobileActions = detail.task.status === "pending" ? (
<>
<section className="mobile-card space-y-3">
{offlineNotice}
{issueAnchors}
{commentEditor}
<div className="grid grid-cols-2 gap-2">
{allowedActions.has("return") || !online ? <Button className="min-h-11" data-testid="mobile-approvals-return" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回</Button> : null}
{allowedActions.has("reject") || !online ? <Button className="min-h-11" data-testid="mobile-approvals-reject" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")} type="button" variant="secondary">驳回</Button> : null}
{canTransfer || !online ? <Button className="min-h-11" data-testid="mobile-approvals-transfer" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="secondary">转审</Button> : null}
</div>
</section>
<MobileStickyActionBar testId="mobile-approvals-sticky-actions">
<Button className="min-h-12 w-full" data-testid="mobile-approvals-approve" disabled={actionDisabled || !allowedActions.has("approve")} onClick={() => requestAction("approve")} type="button">通过</Button>
</MobileStickyActionBar>
</>
) : null;
return (
<>
<ReimbursementMobileWorkspaceShell
capabilities={capabilities}
fallbackTo={backTarget}
model={model}
slots={{
status: <StatusBadge label={workflowTaskStatusLabel(detail.task.status)} status={detail.task.status} />,
beforeEvidence: detail.task.status !== "pending" ? <p className="mobile-card text-sm text-blue-800">当前显示报销单最新版本;审批意见与提交检查保留历史语义。</p> : null,
afterEvidence: snapshot?.budgetCheckStatus === "missing" ? <p className="mobile-card text-sm text-zinc-500" id="reimbursement-workspace-snapshot">该次提交未留存预算快照。</p> : null,
timeline,
mobileActions,
}}
/>
{dialogs}
</>
);
}
const actionPanel = (
<BusinessDetailActionPanel
title="审批决定"
primaryAction={allowedActions.has("approve") ? <Button data-testid="workflow-task-approve" disabled={actionDisabled} onClick={() => requestAction("approve")} type="button">通过</Button> : null}
secondaryActions={(
<>
{allowedActions.has("return") ? <Button data-testid="workflow-task-return-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回</Button> : null}
{allowedActions.has("reject") ? <Button data-testid="workflow-task-reject-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")} type="button" variant="secondary">驳回</Button> : null}
</>
)}
moreActions={canTransfer ? <Button className="w-full" data-testid="workflow-task-transfer-menu-item" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="ghost">转审</Button> : null}
summary={statusSummary}
testId="workflow-task-action-panel"
>
<div className="space-y-3">{issueAnchors}{commentEditor}</div>
</BusinessDetailActionPanel>
);
return (
<>
<ReimbursementDesktopWorkspaceShell
capabilities={capabilities}
model={model}
slots={{
status: <StatusBadge label={workflowTaskStatusLabel(detail.task.status)} status={detail.task.status} />,
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,
}}
/>
{dialogs}
</>
);
}
+4 -3
查看文件
@@ -34,6 +34,7 @@ import {
LeaveRequestPage,
MasterDataPage,
MobileApprovalsPage,
MobileWorkflowTaskRoutePage,
MobileAssetScanPage,
MobileCapturePage,
MobileLeavePage,
@@ -77,7 +78,7 @@ import {
WeeklyReportsPage,
WorkspaceSetupPage,
WorkflowDefinitionsPage,
WorkflowTaskDetailPage,
WorkflowTaskRoutePage,
WorkflowTasksPage,
} from "./lazy";
import { GuestOnly, RequireAuth, RequireBusinessApprover, RequirePermission } from "./guards";
@@ -373,7 +374,7 @@ const routeElements = (
path="workflow/tasks/:taskId"
element={
<RequireBusinessApprover>
{withRouteFallback(<WorkflowTaskDetailPage />)}
{withRouteFallback(<WorkflowTaskRoutePage />)}
</RequireBusinessApprover>
}
/>
@@ -535,7 +536,7 @@ const routeElements = (
"travel.approve",
]}
>
{withRouteFallback(<MobileApprovalsPage />)}
{withRouteFallback(<MobileWorkflowTaskRoutePage />)}
</RequirePermission>
}
/>
@@ -0,0 +1 @@
export { MobileWorkflowTaskRoutePage as default } from "../../pages/MobileWorkflowTaskRoutePage";
@@ -1 +0,0 @@
export { WorkflowTaskDetailPage as default } from "../../pages/WorkflowTaskDetailPage";
@@ -0,0 +1 @@
export { WorkflowTaskRoutePage as default } from "../../pages/WorkflowTaskRoutePage";
+2 -1
查看文件
@@ -17,6 +17,7 @@ export const InvoicePoolPage = lazy(() => import("./InvoicePoolPage"));
export const LeaveRequestPage = lazy(() => import("./LeaveRequestPage"));
export const MasterDataPage = lazy(() => import("./MasterDataPage"));
export const MobileApprovalsPage = lazy(() => import("./MobileApprovalsPage"));
export const MobileWorkflowTaskRoutePage = lazy(() => import("./MobileWorkflowTaskRoutePage"));
export const MobileAssetScanPage = lazy(() => import("./MobileAssetScanPage"));
export const MobileCapturePage = lazy(() => import("./MobileCapturePage"));
export const MobileLeavePage = lazy(() => import("./MobileLeavePage"));
@@ -62,5 +63,5 @@ export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemp
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
export const UserManagementPage = lazy(() => import("./UserManagementPage"));
export const WorkflowDefinitionsPage = lazy(() => import("./WorkflowDefinitionsPage"));
export const WorkflowTaskDetailPage = lazy(() => import("./WorkflowTaskDetailPage"));
export const WorkflowTaskRoutePage = lazy(() => import("./WorkflowTaskRoutePage"));
export const WorkflowTasksPage = lazy(() => import("./WorkflowTasksPage"));
@@ -330,7 +330,12 @@ function workflowTaskDetail(status: "pending" | "completed" = "pending") {
entityType: "reimbursement",
currentStatus: isCompleted ? "approved" : "reviewing",
currentStepId: isCompleted ? null : "step-dg7",
contextData: { standardViolations: [] },
contextData: {
standardViolations: [],
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
},
startedAt: "2026-05-12T08:00:00.000Z",
completedAt: isCompleted ? "2026-05-12T09:00:00.000Z" : null,
},
@@ -842,29 +847,6 @@ async function expectMobileDetailHeaderSpacing(page: Page, testId: string) {
expect(metrics!.gap).toBeGreaterThanOrEqual(12);
}
async function expectApprovalActionsStable(page: Page) {
const actions = await page.evaluate(() =>
Array.from(document.querySelectorAll<HTMLElement>(".mobile-approval-action-grid > button")).map((button) => {
const rect = button.getBoundingClientRect();
return {
left: rect.left,
right: rect.right,
top: rect.top,
width: rect.width,
};
}),
);
expect(actions).toHaveLength(3);
const firstTop = actions[0].top;
for (const action of actions) {
expect(Math.abs(action.top - firstTop)).toBeLessThanOrEqual(2);
expect(action.width).toBeGreaterThanOrEqual(64);
}
expect(actions[0].right).toBeLessThanOrEqual(actions[1].left + 2);
expect(actions[1].right).toBeLessThanOrEqual(actions[2].left + 2);
}
async function expectActionTileGapStable(page: Page) {
const gaps = await page.evaluate(() =>
Array.from(document.querySelectorAll<HTMLElement>("[data-testid='mobile-action-grid'] .mobile-icon-action")).map((tile) => {
@@ -999,31 +981,24 @@ test.describe("MIF7 移动信息架构整改", () => {
await mobile.page.getByTestId("mobile-pending-summary").getByRole("link").first().click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-list")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expect(mobile.page.getByTestId("mobile-approvals-detail")).not.toContainText("业务单号");
await expect(mobile.page.getByTestId("mobile-approvals-detail")).toContainText("报销单 BX20260512001");
await expect(mobile.page.getByTestId("mobile-approvals-history")).toContainText("提交 · 测试员工");
await expect(mobile.page.getByTestId("mobile-approvals-history")).toContainText("转审 · 测试审批人");
await expect(mobile.page.getByTestId("mobile-approvals-history")).not.toContainText("transferToEmployeeId");
await expectMobileApprovalStatusSlots(mobile.page);
await expectMobileDetailHeaderSpacing(mobile.page, "mobile-approvals-detail-header");
await expectApprovalActionsStable(mobile.page);
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).not.toContainText("业务单号");
await expect(mobile.page.getByTestId("mobile-reimbursement-detail-header")).toContainText("BX20260512001");
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toContainText("提交 · 测试员工");
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toContainText("转审 · 测试审批人");
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).not.toContainText("transferToEmployeeId");
await expectMobileDetailHeaderSpacing(mobile.page, "mobile-reimbursement-detail-header");
await expect(mobile.page.getByTestId("mobile-approvals-sticky-actions")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-approvals-approve")).toHaveCSS("min-height", "48px");
await expectNoMobileShellRegression(mobile.page);
await mobile.page.screenshot({ path: screenshotPath("manager-approval-push-detail"), fullPage: true });
await mobile.page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
await expect(mobile.page).toHaveURL(/\/mobile$/);
await mobile.page.getByTestId("mobile-pending-summary").getByRole("link").first().click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
await mobile.page.getByTestId("mobile-approval-reimbursement-detail-link").click();
await expect(mobile.page).toHaveURL(/\/mobile\/reimbursements\/reimbursement-dg7$/);
await expect(mobile.page.getByTestId("mobile-reimbursement-items")).toBeVisible();
await mobile.page.getByTestId("mobile-reimbursement-detail-header").getByRole("button", { name: "返回" }).click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7$/);
await expectApprovalActionsStable(mobile.page);
await expect(mobile.page).toHaveURL(/\/mobile$/);
await mobile.page.goto("/mobile/approvals");
await expect(mobile.page.getByRole("tab", { name: "待我审批" })).toBeVisible();
@@ -1034,9 +1009,10 @@ test.describe("MIF7 移动信息架构整改", () => {
await expect(mobile.page.getByTestId("mobile-approvals-task-task-dg7-completed")).toBeVisible();
await mobile.page.getByTestId("mobile-approvals-task-task-dg7-completed").click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\/task-dg7-completed$/);
await expect(mobile.page.getByTestId("mobile-approvals-detail-header")).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-detail-header")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await mobile.page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
await mobile.page.getByTestId("mobile-reimbursement-detail-header").getByRole("button", { name: "返回" }).click();
await expect(mobile.page).toHaveURL(/\/mobile\/approvals\?taskTab=completed$/);
await expect(mobile.page.getByRole("tab", { name: "已处理" })).toHaveAttribute("data-state", "active");
await mobile.page.screenshot({ path: screenshotPath("approver-task-tabs"), fullPage: true });
@@ -1130,8 +1106,8 @@ test.describe("MIF7 移动信息架构整改", () => {
await expect(mobile.page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
const sectionOrder = await mobile.page.evaluate(() =>
[
"mobile-reimbursement-items",
"mobile-reimbursement-attachments",
"approval-item-evidence",
"approval-attachments",
"mobile-reimbursement-processing-progress",
].map((testId) => document.querySelector(`[data-testid='${testId}']`)?.getBoundingClientRect().top ?? -1),
);
@@ -53,18 +53,52 @@ test("R10 移动审批与移动报销复用同一底部动作容器", () => {
const sharedActionBar = source("src/components/patterns/MobileStickyActionBar.tsx");
const businessEditTemplate = source("src/components/patterns/BusinessEditTemplate.tsx");
const mobileApprovals = source("src/pages/MobileApprovalsPage.tsx");
const reimbursementApproval = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
const mobileReimbursement = source("src/pages/MobileReimbursementDetailPage.tsx");
const reimbursementMobileShell = source("src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx");
expect(sharedActionBar).toContain("mobile-sticky-action-surface fixed inset-x-0 bottom-0");
expect(sharedActionBar).toContain("env(safe-area-inset-bottom,0px)");
expect(businessEditTemplate).toContain("MOBILE_STICKY_ACTION_PAGE_CLASS");
expect(businessEditTemplate).toContain("<MobileStickyActionBar");
expect(mobileApprovals).toContain("<MobileStickyActionBar");
expect(reimbursementApproval).toContain("<MobileStickyActionBar");
expect(mobileReimbursement).toContain("<MobileStickyActionBar");
expect(reimbursementMobileShell).toContain("MOBILE_STICKY_ACTION_PAGE_CLASS");
expect(reimbursementMobileShell).toContain("slots.mobileActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : undefined");
expect(mobileApprovals).not.toContain('className="mobile-sticky-action-surface fixed');
expect(reimbursementApproval).not.toContain('className="mobile-sticky-action-surface fixed');
expect(mobileReimbursement).not.toContain('className="mobile-sticky-action-surface fixed');
});
test("B-2b 报销工作台双端共享内容合同且 task detail 只由路由查询一次", () => {
const desktopShell = source("src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx");
const mobileShell = source("src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx");
const desktopRoute = source("src/pages/WorkflowTaskRoutePage.tsx");
const mobileRoute = source("src/pages/MobileWorkflowTaskRoutePage.tsx");
const approvalAdapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
const workflowDetail = source("src/pages/WorkflowTaskDetailPage.tsx");
const mobileApprovals = source("src/pages/MobileApprovalsPage.tsx");
for (const shell of [desktopShell, mobileShell]) {
expect(shell).toContain("ReimbursementWorkspaceModel");
expect(shell).toContain("ReimbursementWorkspaceSlots");
expect(shell).toContain("ReimbursementEvidenceCapabilities");
expect(shell).toContain("<ReimbursementItemEvidenceList");
expect(shell.indexOf("slots.beforeEvidence")).toBeLessThan(shell.indexOf("<ReimbursementItemEvidenceList"));
expect(shell.indexOf("<ReimbursementItemEvidenceList")).toBeLessThan(shell.indexOf("slots.afterEvidence"));
expect(shell.indexOf("slots.afterEvidence")).toBeLessThan(shell.indexOf("slots.timeline"));
}
expect(desktopRoute.match(/fetchWorkflowTaskDetail\(/g)).toHaveLength(1);
expect(mobileRoute.match(/fetchWorkflowTaskDetail\(/g)).toHaveLength(1);
expect(approvalAdapter).not.toContain("fetchWorkflowTaskDetail");
expect(workflowDetail).not.toContain("ReimbursementItemEvidenceList");
expect(workflowDetail).not.toContain("提交时检查");
expect(mobileApprovals).not.toContain("selectedTask?.reimbursement");
expect(mobileApprovals).not.toContain("reimbursementDecisionReady");
});
test("R1/R6 未知状态和个人事项状态不回落到技术枚举", () => {
const statusBadge = source("src/components/common/StatusBadge.tsx");
const todosPage = source("src/pages/TodosPage.tsx");
@@ -351,9 +351,9 @@ test("E2E 移动审批:报销证据不进入 IndexedDB 且离线动作 fail cl
}
await page.context().setOffline(true);
await expect(page.getByText("请联网加载完整明细和票据后再处理该报销审批")).toBeVisible();
await expect(page.getByText("报销审批需联网核对完整报销内容;当前不能通过、退回、驳回或转审。")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-approve")).toBeDisabled();
await page.getByTestId("mobile-approvals-more").click();
await expect(page.getByTestId("mobile-approvals-return")).toBeDisabled();
await expect(page.getByTestId("mobile-approvals-reject")).toBeDisabled();
await expect(page.getByTestId("mobile-approvals-transfer")).toBeDisabled();
});
@@ -221,7 +221,12 @@ const workflowDetail = {
entityType: "reimbursement",
currentStatus: "reviewing",
currentStepId: "manager-review",
contextData: { standardViolations: [] },
contextData: {
standardViolations: [],
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
},
startedAt: "2026-05-27T09:00:00.000Z",
completedAt: null,
},
@@ -1252,16 +1257,16 @@ test("已完成审批只保留中文只读摘要", async ({ page }) => {
await page.goto("/workflow/tasks/task-wave3-completed");
await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible();
await expect(page.getByTestId("workflow-task-action-panel")).toContainText("当前审批角色:财务");
await expect(page.getByTestId("workflow-task-action-panel")).toContainText("完成时间:2026年5月27日 18:10");
await expect(page.getByTestId("workflow-task-overview").getByText("可执行动作", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("workflow-task-action-panel")).toContainText("审批角色:财务");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 18:10");
await expect(page.getByTestId("workflow-task-approve")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-return-toggle")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-reject-toggle")).toHaveCount(0);
await expect(page.getByText("更多", { exact: true })).toHaveCount(0);
await expect(page.getByText("通过 · 王顺理", { exact: true })).toBeVisible();
await expect(page.getByText("2026年5月27日 18:10", { exact: true })).toBeVisible();
await expect(page.getByTestId("workflow-task-back")).toContainText("返回审批列表");
await expect(page.getByRole("link", { name: "返回审批列表" })).toBeVisible();
});
test("1280px 资产详情保持可用双栏和可点击操作区", async ({ page }) => {
@@ -1296,49 +1301,55 @@ test("1280px 资产详情保持可用双栏和可点击操作区", async ({ page
test("报销详情固定使用上海时区并收敛只读信息", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/reimbursements/reim-wave3");
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: "当前状态与下一步" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "业务摘要" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-back")).toContainText("返回报销列表");
await expect(page.getByTestId("reimbursement-summary")).toContainText("¥1,860.00");
await expect(page.getByTestId("reimbursement-items")).toContainText("¥860.00");
await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const pcInvoicePreview = page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看发票原件/ });
const pcAttachmentPreview = page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ });
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥1,860.00");
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("¥860.00");
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const pcInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ });
const pcMaterialPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: "查看完整材料" });
const pcAttachmentPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ });
await pcInvoicePreview.focus();
await expect(pcInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(pcMaterialPreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(pcAttachmentPreview).toBeFocused();
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("负责人复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/);
await page.goto("/workflow/tasks/task-wave3");
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 17:00");
await expect(page.getByText(`提交 · ${visualMe.name}`)).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/reimbursements/reim-wave3");
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-detail-header")).toBeVisible();
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name);
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText("¥1,860.00");
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("¥860.00");
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText(visualMe.name);
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("¥1,860.00");
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("¥860.00");
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("负责人复核");
await expect(page.getByTestId("mobile-reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const mobileInvoicePreview = page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ });
const mobileAttachmentPreview = page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ });
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
const mobileInvoicePreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看发票原件/ });
const mobileMaterialPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: "查看完整材料" });
const mobileAttachmentPreview = page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /查看补充附件/ });
await mobileInvoicePreview.focus();
await expect(mobileInvoicePreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(mobileMaterialPreview).toBeFocused();
await page.keyboard.press("Tab");
await expect(mobileAttachmentPreview).toBeFocused();
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款完成");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
@@ -1351,15 +1362,13 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/reimbursements/reim-wave3");
await expect(page.getByRole("heading", { name: /报销详情/ })).toBeVisible();
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
await expect(page.getByTestId("reimbursement-summary")).toBeVisible();
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: `报销单 ${reimbursementDetail.formNumber}` })).toBeVisible();
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
await expect(page.getByRole("heading", { name: "当前状态与下一步" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "业务摘要" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
await expect(page.getByTestId("reimbursement-first-screen-actions")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-action-panel")).toBeVisible();
await expect(page.getByTestId("reimbursement-action-panel").getByRole("button", { name: "撤回" })).toBeVisible();
@@ -1372,7 +1381,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.goto("/workflow/tasks/task-wave3");
await page.setViewportSize({ width: 1100, height: 768 });
await expect(page.getByRole("heading", { name: "审批详情" })).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.locator(".app-shell-sidebar")).toBeHidden();
await expect(page.getByRole("button", { name: "打开菜单" })).toBeVisible();
await expect(page.locator(".detail-workspace-grid")).toHaveCSS("grid-template-columns", /.+ .+/);
@@ -1383,12 +1392,11 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await expect(page.getByRole("button", { name: "打开菜单" })).toBeHidden();
await expect(page.getByTestId("workflow-task-next-action")).toHaveCount(0);
await expect(page.getByText("审批动作")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "处理", exact: true })).toBeVisible();
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
await expect(page.locator(".workflow-action-panel")).toHaveCSS("position", "sticky");
await expect(page.locator(".workflow-action-panel")).toHaveCSS("top", "96px");
await expect(page.locator(".workflow-action-panel")).toHaveCSS("z-index", "30");
await expect(page.getByTestId("workflow-task-action-panel")).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月27日 17:00");
const workflowActionPanel = page.getByTestId("workflow-task-action-panel").locator("xpath=ancestor::section[1]");
await expect(workflowActionPanel).toHaveCSS("position", "sticky");
await expect(workflowActionPanel).toHaveCSS("top", "96px");
const workflowLayout = await page.locator(".detail-workspace-grid").evaluate((workspace) => {
const [main, aside] = Array.from(workspace.children) as HTMLElement[];
return {
@@ -1402,20 +1410,15 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
expect(workflowLayout.mainWidth).toBeGreaterThanOrEqual(560);
expect(workflowLayout.asideWidth).toBeGreaterThanOrEqual(280);
expect(workflowLayout.overflow).toBeLessThanOrEqual(2);
const formNumberLineCount = await page.getByText(reimbursementDetail.formNumber, { exact: true }).evaluate((element) => {
const formNumberLineCount = await page.getByRole("heading", { name: `报销单 ${reimbursementDetail.formNumber}` }).evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
return range.getClientRects().length;
});
expect(formNumberLineCount).toBe(1);
await page.setViewportSize({ width: 1440, height: 960 });
await page.getByTestId("workflow-task-reimbursement-detail-link").click();
await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/);
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
await expect(page.getByTestId("reimbursement-back")).toContainText("返回审批详情");
await page.getByTestId("reimbursement-back").click();
await expect(page).toHaveURL(/\/workflow\/tasks\/task-wave3$/);
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await expect(page.getByTestId("workflow-task-reimbursement-detail-link")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await page.screenshot({ path: screenshotPath("02-pc-workflow-detail.png"), fullPage: true });
await page.goto("/notifications");
@@ -1458,20 +1461,18 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
});
test("PC 报销详情按来源返回并保留列表筛选", async ({ page }) => {
test("PC 报销审批只接受安全审批列表返回目标", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/reimbursements?status=reviewing&page=1&scope=all");
await page.getByTestId("reimbursement-detail-link").first().click();
await expect(page).toHaveURL(/\/workflow\/tasks\/task-wave3$/);
await expect(page.getByTestId("workflow-task-back")).toContainText("返回审批列表");
await page.getByTestId("workflow-task-back").click();
await expect(page).toHaveURL(/\/reimbursements\?status=reviewing&page=1&scope=all$/);
await expect(page.getByRole("link", { name: "返回审批列表" })).toBeVisible();
await page.getByRole("link", { name: "返回审批列表" }).click();
await expect(page).toHaveURL(/\/workflow\/tasks$/);
await page.goto("/workflow/tasks/task-wave3");
await page.getByTestId("workflow-task-reimbursement-detail-link").click();
await expect(page.getByTestId("reimbursement-back")).toContainText("返回审批详情");
await page.getByTestId("reimbursement-back").click();
await expect(page).toHaveURL(/\/workflow\/tasks\/task-wave3$/);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("workflow-task-reimbursement-detail-link")).toHaveCount(0);
});
test("/contracts-registry 在 768x1024 与 1024x768 下操作列可达", async ({ page }) => {
@@ -1549,14 +1550,14 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("05-mobile-reimbursements.png"), fullPage: true });
await page.goto("/mobile/reimbursements/reim-wave3");
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toBeVisible();
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name);
await expect(page.getByTestId("mobile-reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText(visualMe.name);
await expect(page.getByTestId("approval-attachments")).toContainText("现场说明.pdf");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-items")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.locator("table")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: screenshotPath("06-mobile-reimbursement-detail.png"), fullPage: true });
@@ -1570,8 +1571,8 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("07-mobile-approvals.png"), fullPage: true });
await page.goto("/mobile/approvals/task-wave3");
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-detail-focus")).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.locator("table")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: screenshotPath("08-mobile-approval-detail.png"), fullPage: true });
@@ -105,6 +105,7 @@ function reimbursementDetail(overrides: { reimbursementDate?: string; descriptio
},
],
invoices: [],
attachments: [],
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
@@ -376,11 +377,11 @@ test.describe("R102 reimbursement main flow", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByRole("heading", { name: `报销详情 ${formNumber}` })).toBeVisible();
await expect(page.getByRole("heading", { name: `报销单 ${formNumber}` })).toBeVisible();
await expect(page.getByText("报销ID")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-business-summary")).toBeVisible();
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.getByTestId("approval-attachments")).toBeVisible();
await expect(page.getByText("执行支付")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0);
await page.getByTestId("reimbursement-more-toggle").click();
@@ -413,20 +414,20 @@ test.describe("R102 reimbursement main flow", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-summary")).toContainText("报销总额");
await expect(page.getByTestId("reimbursement-items-table")).toContainText("共 1 条明细");
await expect(page.getByTestId("reimbursement-items-table")).toContainText("报销合计");
await expect(page.getByTestId("reimbursement-operational-hints")).toContainText("待关联发票或补充无票说明");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥168.30");
await expect(page.getByTestId("approval-item-evidence")).toContainText("明细 1");
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
await expect(page.getByTestId("approval-item-evidence")).toContainText("这条明细尚无对应票据");
await page.getByTestId("reimbursement-business-summary").getByRole("button", { name: "编辑", exact: true }).click();
await page.getByRole("button", { name: "编辑", exact: true }).click();
const editor = page.getByTestId("reimbursement-header-editor");
await editor.getByLabel("报销日期").fill("2026-05-07");
await editor.getByLabel("报销事由").fill("办公网络费用");
await editor.getByRole("button", { name: "保存", exact: true }).click();
await expect(page.getByTestId("reimbursement-header-editor")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-business-summary")).toContainText("办公网络费用");
await expect(page.getByTestId("reimbursement-summary")).toContainText("2026-05-07");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("办公网络费用");
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("2026年5月7日");
});
test("移动报销卡片显示业务编号", async ({ page }) => {
@@ -448,8 +449,8 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page.locator("table:visible")).toHaveCount(0);
await page.getByTestId("mobile-reimbursement-card").getByRole("link").click();
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("共 1 条明细");
await expect(page.getByTestId("mobile-reimbursement-items")).toContainText("报销合计 ¥168.30");
await expect(page.getByTestId("reimbursement-approval-evidence").getByTestId("approval-item-evidence")).toHaveCount(1);
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("¥168.30");
});
test("管理员查看他人报销时保持只读并可在移动端切换全部报销", async ({ page }) => {
@@ -526,15 +527,13 @@ test.describe("R102 reimbursement main flow", () => {
await expect(page).toHaveURL(/\/portal/);
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${otherReimbursementId}`);
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-withdraw")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-withdraw-comment")).toHaveCount(0);
await expect(page.getByText("只读查看")).toBeVisible();
await expect(page.getByText("等待报销人完善")).toBeVisible();
await expect(page.getByTestId("reimbursement-item-add")).toHaveCount(0);
await expect(page.getByRole("combobox", { name: "关联项目" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-invoices")).toContainText("明细:未关联");
await expect(page.getByTestId("reimbursement-invoices")).toContainText("已关联");
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("R102-OTHER");
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
await expect(page.getByText("草稿或驳回状态可移除")).toHaveCount(0);
await page.setViewportSize({ width: 375, height: 812 });
@@ -565,9 +564,9 @@ test.describe("R102 reimbursement main flow", () => {
await page.getByText("其他员工").click();
await expect(page).toHaveURL(new RegExp(`/mobile/reimbursements/${otherReimbursementId}$`));
await expect(page.getByText("只读查看")).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-items").getByRole("button", { name: "新增" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-invoices")).toContainText("明细:未关联");
await expect(page.getByTestId("reimbursement-invoices")).toContainText("已关联");
await expect(page.getByTestId("reimbursement-approval-evidence").getByRole("button", { name: /新增|编辑|删除/ })).toHaveCount(0);
await expect(page.getByTestId("approval-unlinked-invoices")).toBeVisible();
await expect(page.getByTestId("approval-item-evidence")).toContainText("所属票据(0)");
await expect(page.getByRole("button", { name: "移除" })).toHaveCount(0);
const routeState = await page.evaluate(() => window.history.state?.usr ?? null);
expect(routeState?.from).toContain("status=reviewing");
@@ -585,7 +584,7 @@ test.describe("R102 reimbursement main flow", () => {
test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-item-edit").click();
await page.getByTestId("approval-item-evidence").getByRole("button", { name: "编辑明细" }).click();
const addItem = page.getByTestId("reimbursement-item-add");
await addItem.click();
@@ -156,7 +156,7 @@ test.describe.serial("R109 企业微信移动报销反馈专项", () => {
await employee.page.screenshot({ path: screenshotPath("mobile-reimbursements") });
await card.getByRole("link").click();
await expect(employee.page).toHaveURL(new RegExp(`/mobile/reimbursements/${seeded.id}`));
await expect(employee.page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(employee.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await employee.page.screenshot({ path: screenshotPath("mobile-reimbursement-detail") });
const detailMetrics = await collectMobileMetrics(employee.page);
@@ -131,7 +131,7 @@ test.describe.serial("R110 手机和 PC 路由分流", () => {
await mobile.page.goto(`/reimbursements/${seeded.id}`);
await expect(mobile.page).toHaveURL(new RegExp(`/mobile/reimbursements/${seeded.id}$`));
await expect(mobile.page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expectNoPcShell(mobile.page);
await mobile.page.goto(`/reimbursements/${seeded.id}/print`);
@@ -810,11 +810,8 @@ async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
);
await confirmDialog.getByRole("button", { name: "确认提交", exact: true }).click();
if (options.mobile) {
await expect(page.getByTestId("mobile-reimbursement-invoice-guard-summary")).toContainText("共 2 项需要处理");
} else {
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
}
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
await expect(confirmDialog).toHaveCount(0);
await expect(page.locator("div.fixed.inset-0[data-state='open']")).toHaveCount(0);
const firstGuardIssue = page.locator('[data-reimbursement-guard-issue="true"]').first();
@@ -827,7 +824,7 @@ async function exerciseInvoiceGuardRecoveryAfterPrecheckConfirm(
invoiceId: invoice.id,
linkedItemId: targetItemId,
}]);
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toHaveCount(0);
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toHaveCount(0);
await page.getByTestId("reimbursement-submit").click();
await expect(confirmDialog).toBeVisible();
@@ -853,18 +850,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByText("报销详情 BX20260508001")).toBeVisible();
await expect(page.getByRole("heading", { name: "报销单 BX20260508001" })).toBeVisible();
await expect(page.getByText("预算与审核提示")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
const pcSectionOrder = await page.locator("[data-testid='reimbursement-overview'], [data-testid='reimbursement-items'], [data-testid='reimbursement-materials'], [data-testid='reimbursement-workflow-progress']")
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(pcSectionOrder).toEqual([
"reimbursement-overview",
"reimbursement-workflow-progress",
"reimbursement-items",
"reimbursement-materials",
]);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.getByTestId("approval-attachments")).toBeVisible();
await expect(page.getByTestId("reimbursement-workflow-progress")).toBeVisible();
await expect(page.locator('a[href="#reimbursement-workflow-progress-panel"]')).toHaveCount(0);
await expect(page.locator('a[href="#reimbursement-processing-progress-panel"]')).toHaveCount(0);
await expect(page.getByText("ERP")).toHaveCount(0);
@@ -882,29 +873,23 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.keyboard.press("Escape");
await expect(page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible();
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
await expect(page.getByTestId("reimbursement-materials")).toContainText("数电发票 · 12345678");
await expect(page.getByTestId("reimbursement-materials")).toContainText("电子火车票 · 12345679");
await expect(page.getByTestId("reimbursement-materials")).not.toContainText("无代码");
await expect(page.getByTestId("reimbursement-materials")).not.toContainText("未识别销售方");
const itemRow = page.getByTestId("reimbursement-item-row");
await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
await expect(itemRow).not.toContainText("电子火车票");
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("12345678");
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("12345679");
await expect(page.getByTestId("reimbursement-approval-evidence")).not.toContainText("无代码");
await expect(page.getByTestId("reimbursement-approval-evidence")).not.toContainText("未识别销售方");
await expect(page.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
await expect(page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
await page.locator("#reimbursement-attachment-upload").setInputFiles({
name: "付款截图.png",
mimeType: "image/png",
buffer: Buffer.from("r111 attachment"),
});
await page.getByRole("button", { name: "上传附件" }).click();
await page.getByRole("button", { name: /上传.*附件/ }).click();
await expect(page.getByTestId("approval-attachments")).toContainText("付款截图.png");
await expect(page.getByRole("button", { name: "查看补充附件:付款截图.png" })).toBeVisible();
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("合成差旅概要");
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("测试地点");
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
await page.getByTestId("reimbursement-materials-open-all").click();
await page.getByRole("button", { name: "查看完整材料" }).click();
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0])).toContain("previewToken=merged-pdf");
@@ -943,11 +928,11 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await login(page);
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByText("该金额通常应选择办公电子设备。")).toBeVisible();
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toBeVisible();
await page.getByTestId("reimbursement-item-add").click();
await page.locator("#expenseTypeId").selectOption("expense-type-office-supplies-r111");
await page.getByTestId("reimbursement-item-amount").fill("2000.00");
await expect(page.getByText("该金额通常应选择办公电子设备。")).toHaveCount(2);
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toHaveCount(2);
await page.locator("#expenseTypeId").selectOption("expense-type-assets-r111");
await expect(page.getByTestId("reimbursement-item-asset-approval-basis")).toHaveCount(0);
await expect(page.getByText(/请在明细说明中写明物品名称、实际使用人或责任人、存放位置/)).toBeVisible();
@@ -980,11 +965,11 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.keyboard.press("Escape");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
await expect(page.getByText("该金额通常应选择办公电子设备。")).toBeVisible();
await page.getByRole("button", { name: "新增", exact: true }).click();
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toBeVisible();
await page.getByRole("button", { name: "新增明细", exact: true }).click();
await page.getByLabel("费用类型").selectOption("expense-type-office-supplies-r111");
await page.getByRole("textbox", { name: "金额", exact: true }).fill("2000.00");
await expect(page.getByText("该金额通常应选择办公电子设备。")).toHaveCount(2);
await expect(page.getByText(/该金额通常应选择办公电子设备/)).toHaveCount(2);
await page.getByLabel("费用类型").selectOption("expense-type-assets-r111");
await expect(page.getByText("事前批准依据", { exact: true })).toHaveCount(0);
await page.getByRole("textbox", { name: "说明", exact: true }).fill("合成设备,测试员工使用,测试位置");
@@ -1013,8 +998,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByRole("button", { name: "提交报销", exact: true })).toBeVisible();
await expect(page.getByText("提交审批", { exact: true })).toBeVisible();
await expect(page.getByTestId("reimbursement-overview").getByText("添加发票", { exact: true })).toHaveCount(0);
await expect(page.getByText("下一步:提交审批", { exact: true })).toBeVisible();
await expect(page.getByText("所属票据(0)")).toHaveCount(0);
await page.getByRole("button", { name: "提交报销", exact: true }).click();
await expect.poll(() => state.submitRequestCount).toBe(1);
});
@@ -1118,13 +1103,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.getByTestId("reimbursement-submit").click();
await expect(page.getByTestId("reimbursement-action-error").first()).toContainText("1 张发票未选择报销明细");
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible();
await expect(page.getByText("关联发票的票面金额未识别,请重试识别或联系财务处理。")).toBeVisible();
await expect(
page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
).toBeVisible();
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toBeVisible();
await expect(page.getByText("当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。")).toBeVisible();
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
await expect(page.getByText("票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。")).toBeVisible();
await expect(page.getByText(
"该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。",
)).toBeVisible();
@@ -1136,9 +1118,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.getByLabel("选择报销明细").selectOption("item-missing-r111");
state.invoiceItemLinkError = { invoice: "invalid_unlinked_item_state" };
await page.getByRole("button", { name: "确认关联" }).click();
await expect(
page.getByTestId("reimbursement-invoices").getByText("这张发票的关联状态已变化,请刷新后重试。"),
).toBeVisible();
await expect(page.getByTestId("approval-unlinked-invoices").getByText("这张发票的关联状态已变化,请刷新后重试。")).toBeVisible();
expect(state.invoices.find((invoice) => invoice.id === unlinkedInvoiceId)?.linkedItemId).toBeNull();
state.invoiceItemLinkError = null;
await page.getByRole("button", { name: "确认关联" }).click();
@@ -1149,10 +1129,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
invoiceId: unlinkedInvoiceId,
linkedItemId: "item-missing-r111",
}]);
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toHaveCount(0);
await expect(
page.getByText("申报金额 ¥1000.00 高于关联发票合计 ¥600.00,请补充发票或调整申报金额。"),
).toBeVisible();
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toHaveCount(0);
await expect(page.getByText("当前申报金额高于已读取的票面金额合计,暂时不能据此通过;请核对明细金额和票据是否完整。")).toBeVisible();
const structuredGuardDetails = state.submitGuardDetails;
state.submitGuardDetails = {
@@ -1176,10 +1154,10 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-submit").click();
await expect(page.getByTestId("mobile-reimbursement-invoice-guard-summary")).toContainText("共 4 项需要处理");
await expect(page.getByText("尚未关联正式发票,请到“报销材料”添加发票并选择本明细。")).toBeVisible();
await expect(page.getByText("这张发票还没有选择报销明细,请在下方完成关联。")).toBeVisible();
await expect(page.getByText("票面金额未自动识别,需由财务查看原件核对。")).toBeVisible();
await expect(page.getByTestId("reimbursement-action-error")).toContainText("票据检查未通过");
await expect(page.getByText("这条明细尚无对应票据,暂时无法提交;请添加票据并选择这条明细。")).toBeVisible();
await expect(page.getByText("这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。")).toBeVisible();
await expect(page.getByText("票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。")).toBeVisible();
await expect(page.getByText(
"该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。",
)).toBeVisible();
@@ -1217,8 +1195,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
try {
await login(mobile.page);
await mobile.page.goto(`/mobile/reimbursements/${reimbursementId}`);
await expect(mobile.page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "报销材料" })).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "明细与票据" })).toBeVisible();
const beforeDialogMetrics = await collectVisualMetrics(mobile.page);
expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2);
@@ -1276,36 +1254,31 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
).toBeVisible({ timeout: 15_000 });
await mobile.page.keyboard.press("Escape");
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("数电发票 · 12345678");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("电子火车票 · 12345679");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("无代码");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("未识别销售方");
const mobileSectionOrder = await mobile.page.locator("[data-testid='mobile-reimbursement-detail-focus'], [data-testid='mobile-reimbursement-items'], [data-testid='mobile-reimbursement-materials'], [data-testid='mobile-reimbursement-processing-section']")
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(mobileSectionOrder).toEqual([
"mobile-reimbursement-detail-focus",
"mobile-reimbursement-processing-section",
"mobile-reimbursement-items",
"mobile-reimbursement-materials",
]);
const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first();
await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
await expect(mobileItem).not.toContainText("电子火车票");
const mobileEvidence = mobile.page.getByTestId("reimbursement-approval-evidence");
await expect(mobileEvidence).toContainText("12345678");
await expect(mobileEvidence).toContainText("12345679");
await expect(mobileEvidence).not.toContainText("无代码");
await expect(mobileEvidence).not.toContainText("未识别销售方");
const mobileItem = mobile.page.getByTestId("approval-item-evidence").filter({ hasText: "客户招待" }).first();
await expect(mobileItem).toContainText("12345678");
await expect(mobileItem).not.toContainText("12345679");
await expect(mobile.page.getByTestId("approval-unlinked-invoices")).toContainText("12345679");
await expect(mobileEvidence.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
await expect(mobile.page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
await mobile.page.getByRole("button", { name: "添加附件" }).click();
await mobile.page.getByTestId("mobile-reimbursement-materials").locator("input[type='file']").setInputFiles({
const maintenanceSection = mobile.page.locator("section").filter({ has: mobile.page.getByRole("heading", { name: "维护报销内容" }) });
await expect(maintenanceSection.getByRole("button", { name: "选择附件" })).toBeVisible();
await maintenanceSection.locator("input[type='file']").setInputFiles({
name: "付款截图.png",
mimeType: "image/png",
buffer: Buffer.from("r111 mobile attachment"),
});
await mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click();
await maintenanceSection.getByRole("button", { name: "上传 1 个附件" }).click();
await expect(mobile.page.getByRole("button", { name: "查看补充附件:付款截图.png" })).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("合成差旅概要");
await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("测试地点");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
const openAllButton = mobile.page.getByTestId("mobile-reimbursement-materials-open-all");
await expect(mobile.page.getByText("合成差旅概要")).toBeVisible();
await expect(mobile.page.getByText("测试地点")).toBeVisible();
await expect(mobile.page.getByTestId("approval-attachments")).toContainText("补充附件(1)");
const openAllButton = mobile.page.getByRole("button", { name: "查看完整材料" });
const openAllTarget = await openAllButton.boundingBox();
expect(openAllTarget?.height ?? 0).toBeGreaterThanOrEqual(44);
await openAllButton.click();
@@ -424,7 +424,7 @@ test.describe("R5 真机反馈移动交互", () => {
expect(listMetrics.scrollWidth).toBeLessThanOrEqual(listMetrics.width + 2);
await page.goto("/mobile/reimbursements/r5-reimbursement-1");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("mobile-nav-reimbursements")).toHaveCount(0);
await expect(page.getByRole("button", { name: "返回" })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
@@ -577,7 +577,7 @@ test.describe("R6 移动响应式体系", () => {
await expectMobilePageStable(page, device.label, `${device.key}-mobile-leave`);
await page.goto("/mobile/reimbursements/r6-responsive-reimbursement");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
const detailMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-reimbursement-detail`);
@@ -30,7 +30,7 @@ const MOBILE_PAGES = [
{ key: "home", path: "/mobile", marker: "mobile-action-grid", deep: false },
{ key: "approvals", path: "/mobile/approvals", marker: "mobile-approvals-list", deep: false },
{ key: "reimbursements", path: "/mobile/reimbursements", marker: "mobile-reimbursements-list", deep: false },
{ key: "reimbursement-detail", path: "/mobile/reimbursements/mud6-reimbursement", marker: "mobile-reimbursement-detail", deep: true },
{ key: "reimbursement-detail", path: "/mobile/reimbursements/mud6-reimbursement", marker: "reimbursement-mobile-workspace", deep: true },
{ key: "purchase-requests", path: "/mobile/purchase-requests", marker: "mobile-purchase-requests-list", deep: false },
{ key: "me", path: "/mobile/me", marker: "mobile-more-links", deep: false },
{ key: "profile", path: "/mobile/profile", marker: "mobile-profile-page", deep: false },
@@ -563,7 +563,7 @@ async function exerciseMobileRendering(page: Page, key: string, label: string) {
expect(meMetrics.mobileMe.hasDecorativeTopLine, `${label} mobile section top line hidden`).toBe(false);
await page.goto("/mobile/reimbursements/wcm6-reimbursement");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
const detailMetrics = await expectRenderingStable(page, label, `${key}-mobile-reimbursement-detail`);
@@ -94,7 +94,12 @@ const pendingTaskDetail = {
entityType: "reimbursement",
currentStatus: "submitted",
currentStepId: pendingTask.stepId,
contextData: { standardViolations: [] },
contextData: {
standardViolations: [],
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
},
startedAt: "2026-05-05T08:50:00.000Z",
completedAt: null,
},
@@ -115,6 +120,57 @@ const pendingTaskDetail = {
history: [],
};
const pendingReimbursementDetail = {
...pendingTaskDetail.reimbursement,
submitterId: "employee-mock-2",
ownerId: "employee-mock-2",
departmentId: "dept-1",
department: { id: "dept-1", name: "研发" },
submittedAt: "2026-05-05T08:50:00.000Z",
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
invoices: [
{
id: "invoice-r89-1",
status: "bound",
invoiceCode: "044001900111",
invoiceNumber: "R8900001",
invoiceDate: "2026-05-05",
sellerName: "测试供应商",
totalAmount: "1683.00",
originalName: "r89-invoice.pdf",
mimeType: "application/pdf",
linkedItemId: "item-r89-1",
amountEvidenceStatus: "verified",
},
],
attachments: [],
budgetHint: null,
workflowProgress: { currentStepIndex: 0, totalSteps: 1, steps: [] },
processingProgress: { stages: [] },
approvalHistory: [],
riskFlags: [],
items: [
{
id: "item-r89-1",
expenseTypeId: "expense-r89-1",
expenseDate: "2026-05-05",
amount: "1683.00",
description: "R89 移动审批验证",
assetPriorApprovalBasis: null,
expenseType: {
id: "expense-r89-1",
code: "TRAVEL",
name: "交通费",
category: "travel",
enabled: true,
requiresInvoice: true,
},
},
],
};
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
}
@@ -328,6 +384,11 @@ async function mockR89Apis(page: Page) {
return;
}
if (path === `/api/reimbursements/${pendingReimbursementDetail.id}`) {
await fulfillJson(route, pendingReimbursementDetail);
return;
}
if (path === `/api/employees/${mockMe.id}/profile`) {
await fulfillJson(route, {
employee: {
@@ -422,7 +483,8 @@ test.describe("R89 移动底部导航与核心页面", () => {
await expect(page).toHaveURL(/\/mobile\/approvals/);
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
await page.getByTestId(`mobile-approvals-task-${pendingTask.id}`).click();
await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toBeVisible();
await expect(page.getByTestId("reimbursement-approval-evidence")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible();
await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible();
await page.getByTestId("mobile-approvals-approve").click();
@@ -430,13 +492,12 @@ test.describe("R89 移动底部导航与核心页面", () => {
await page.getByTestId("workflow-action-confirm-cancel").click();
await page.context().setOffline(true);
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
await page.getByTestId("mobile-approvals-approve").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("只会保存为待同步草稿");
await page.getByTestId("workflow-action-confirm-cancel").click();
await expect(page.getByTestId("mobile-approvals-approve")).toBeDisabled();
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("报销审批需联网核对完整报销内容");
await page.context().setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await attachScreenshot(page, testInfo, "r97-mobile-approvals.png");
await page.getByTestId("mobile-approvals-detail-header").getByRole("button", { name: "返回" }).click();
await page.getByTestId("mobile-reimbursement-detail-header").getByRole("button", { name: "返回" }).click();
await expect(page).toHaveURL(/\/mobile\/approvals/);
await page.getByTestId("mobile-nav-reimbursements").click();
@@ -315,8 +315,7 @@ test.describe("R7E 审批动作二次确认", () => {
await login(page, MANAGER);
await page.goto(`/workflow/tasks/${pendingTask.id}`);
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await expect(page.getByTestId("workflow-action-consequence-help")).toContainText("退回:");
await expect(page.getByTestId("workflow-action-consequence-help")).toContainText("驳回:");
await expect(page.getByTestId("workflow-action-consequence-help")).toHaveCount(0);
await page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
@@ -324,13 +323,13 @@ test.describe("R7E 审批动作二次确认", () => {
expect(actionCalls.approve).toBe(0);
await page.getByTestId("workflow-task-reject-toggle").click();
await expect(page.getByTestId("workflow-action-consequence-help")).toContainText("流程");
await page.getByTestId("workflow-task-reject-comment").fill("资料不符合要求,请重新发起。");
await page.getByTestId("workflow-task-reject").click();
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("流程将终止");
await page.getByTestId("workflow-action-confirm-cancel").click();
expect(actionCalls.reject).toBe(0);
await page.locator("summary", { hasText: "更多" }).click();
await page.getByTestId("workflow-task-transfer-menu-item").click();
await expect(page.getByTestId("workflow-task-transfer-disabled-reason")).toHaveText("请先选择目标审批人。");
await expect(page.getByTestId("workflow-task-transfer")).toBeDisabled();
@@ -391,6 +390,12 @@ test.describe("R7E 审批动作二次确认", () => {
};
const reimbursementDetail = {
id: reimbursementId,
formNumber: "BX-APPROVAL-001",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "180.00",
description: "客户拜访交通费",
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
items: [{
id: "item-1", expenseTypeId: "type-1", expenseDate: "2026-09-01", amount: "180.00",
description: "客户拜访打车", assetPriorApprovalBasis: null,
@@ -417,10 +422,16 @@ test.describe("R7E 审批动作二次确认", () => {
await login(page, MANAGER);
await page.goto(`/workflow/tasks/${pendingTask.id}`);
await expect(page.getByRole("heading", { name: "报销单 BX-APPROVAL-001" })).toHaveCount(1);
await expect(page.getByText("客户拜访交通费", { exact: true })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("客户拜访打车");
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("FP-002");
await expect(page.getByTestId("reimbursement-approval-checks")).toContainText("该次提交未记录预算快照");
await expect(page.locator("#reimbursement-workspace-snapshot")).toContainText("该次提交未留存预算快照");
await expect(page.getByText("当前申报金额高于已读取的票面金额合计")).toBeVisible();
await expect(page.getByTestId("workflow-task-overview")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-summary")).toHaveCount(0);
await expect(page.getByText("打开完整原始表单")).toHaveCount(0);
await expect(page.getByText(/0 条/)).toHaveCount(0);
await expect(page.getByRole("button", { name: "编辑明细" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "删除明细" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "选择明细" })).toHaveCount(0);
@@ -452,7 +463,6 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByTestId("workflow-task-approve")).toBeDisabled();
await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
await page.locator("summary", { hasText: "更多" }).click();
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeDisabled();
});
});