实现 B-2a 报销审批内容工作台(reimbursement-approval-content-workbench)
这个提交包含在:
@@ -5,7 +5,15 @@ import { Link, useLocation, useNavigate, useParams, useSearchParams } from "reac
|
||||
import { toast } from "sonner";
|
||||
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
|
||||
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
|
||||
import { fetchReimbursements, type ReimbursementListItem } from "../api/reimbursements";
|
||||
import {
|
||||
downloadReimbursementAttachment,
|
||||
fetchReimbursementAttachmentPreviewUrl,
|
||||
fetchReimbursementDetail,
|
||||
fetchReimbursementMaterialPreviewBundle,
|
||||
fetchReimbursements,
|
||||
type ReimbursementListItem,
|
||||
} from "../api/reimbursements";
|
||||
import { fetchInvoiceSignedUrl } from "../api/invoicePool";
|
||||
import { isApiError } from "../api/types";
|
||||
import {
|
||||
approveWorkflowTask,
|
||||
@@ -21,6 +29,8 @@ 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";
|
||||
@@ -36,6 +46,13 @@ 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 {
|
||||
@@ -370,6 +387,7 @@ export function MobileApprovalsPage() {
|
||||
const [transferReason, setTransferReason] = useState("");
|
||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
||||
const [showSecondaryActions, setShowSecondaryActions] = useState(false);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const autoSyncRequestedRef = useRef(false);
|
||||
|
||||
@@ -543,6 +561,51 @@ export function MobileApprovalsPage() {
|
||||
);
|
||||
|
||||
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 selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
||||
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
|
||||
const selectedDetailReference = selectedDisplayReference ??
|
||||
@@ -571,6 +634,10 @@ export function MobileApprovalsPage() {
|
||||
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(
|
||||
() =>
|
||||
@@ -599,6 +666,10 @@ export function MobileApprovalsPage() {
|
||||
});
|
||||
|
||||
function requestMobileWorkflowAction(action: WorkflowConfirmAction) {
|
||||
if (isSelectedReimbursement && !reimbursementDecisionReady) {
|
||||
setError("报销审批需联网加载完整明细、票据和提交时检查后才能处理。");
|
||||
return;
|
||||
}
|
||||
if (!canUseRemote && action === "return") {
|
||||
setError("当前离线不支持退回,请联网后再处理。");
|
||||
return;
|
||||
@@ -618,6 +689,31 @@ 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 [];
|
||||
@@ -641,6 +737,9 @@ export function MobileApprovalsPage() {
|
||||
if (!selectedTask) {
|
||||
throw new Error("当前审批任务尚未缓存,请联网加载后再操作");
|
||||
}
|
||||
if (selectedTask.workflowInstance?.entityType === "reimbursement") {
|
||||
throw new Error("报销审批不能保存离线草稿,请联网核对完整内容后处理");
|
||||
}
|
||||
if (!selectedTask.allowedActions.includes(action)) {
|
||||
throw new Error("当前审批任务不允许该动作");
|
||||
}
|
||||
@@ -683,6 +782,9 @@ 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("当前审批任务不允许该动作");
|
||||
}
|
||||
@@ -842,7 +944,9 @@ export function MobileApprovalsPage() {
|
||||
: "最近 30 天已处理审批用于复核,不支持再次审批。";
|
||||
const selectedActionText = selectedTask
|
||||
? selectedTaskAllowsActions
|
||||
? canUseRemote
|
||||
? isSelectedReimbursement && !reimbursementDecisionReady
|
||||
? "报销审批需联网加载完整明细、票据和提交时检查;内容就绪前不能通过、退回、驳回或保存草稿。"
|
||||
: canUseRemote
|
||||
? "核对摘要和历史后,可在底部通过、驳回或退回。"
|
||||
: "当前离线,仅支持保存通过或驳回草稿,联网后再同步。"
|
||||
: "当前任务已处理,本页只展示业务摘要和处理历史。"
|
||||
@@ -877,7 +981,10 @@ 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 ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
|
||||
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}
|
||||
{isSelectedReimbursement
|
||||
? "当前报销任务只显示摘要,不能保存审批草稿;请联网核对完整内容。"
|
||||
: "仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -1168,6 +1275,61 @@ export function MobileApprovalsPage() {
|
||||
查看完整报销单
|
||||
</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 ? (
|
||||
@@ -1231,6 +1393,7 @@ 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}
|
||||
@@ -1238,45 +1401,67 @@ export function MobileApprovalsPage() {
|
||||
/>
|
||||
|
||||
{!canUseRemote ? (
|
||||
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{isSelectedReimbursement
|
||||
? "报销审批离线时不能保存审批草稿,请联网核对完整内容。"
|
||||
: "离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{showSecondaryActions ? (
|
||||
<div className="mobile-list-card space-y-3" data-testid="mobile-approvals-secondary-actions">
|
||||
<p className="text-sm font-medium text-zinc-900">其他处理</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
className="min-h-11 w-full"
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !reimbursementDecisionReady || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => requestMobileWorkflowAction("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-11 w-full"
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={isActionSubmitting || !reimbursementDecisionReady || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => requestMobileWorkflowAction("reject")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MobileStickyActionBar
|
||||
contentClassName="mobile-approval-action-grid"
|
||||
contentClassName="grid grid-cols-[minmax(0,1fr)_auto] gap-2"
|
||||
testId="mobile-approvals-sticky-actions"
|
||||
>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-approve"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
|
||||
disabled={isActionSubmitting || !reimbursementDecisionReady || !selectedTask.allowedActions.includes("approve")}
|
||||
onClick={() => requestMobileWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-reject"
|
||||
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
|
||||
onClick={() => requestMobileWorkflowAction("reject")}
|
||||
aria-expanded={showSecondaryActions}
|
||||
className="min-h-12 px-4"
|
||||
data-testid="mobile-approvals-more"
|
||||
onClick={() => setShowSecondaryActions((current) => !current)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-approvals-return"
|
||||
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
||||
onClick={() => requestMobileWorkflowAction("return")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
退回
|
||||
更多处理
|
||||
</Button>
|
||||
</MobileStickyActionBar>
|
||||
|
||||
{canTransfer ? (
|
||||
{canTransfer && showSecondaryActions ? (
|
||||
<div className="mobile-list-card space-y-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<Label htmlFor="mobile-approvals-transfer-to">目标审批人</Label>
|
||||
@@ -1463,6 +1648,7 @@ export function MobileApprovalsPage() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{downloadReasonDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户