实现 B-2a 报销审批内容工作台(reimbursement-approval-content-workbench)

这个提交包含在:
2026-09-01 20:32:34 +08:00
父节点 4c8ee07abb
当前提交 c840574d36
共修改 36 个文件,包含 2271 行新增和 91 行删除
+208 -22
查看文件
@@ -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}
</>
);
}
@@ -81,7 +81,7 @@ import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { cn } from "../lib/utils";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints";
import {
focusFirstInvoiceGuardIssue,
invoiceGuardInvoiceMessages,
+1 -1
查看文件
@@ -91,10 +91,10 @@ import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFi
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
import {
ReimbursementItemEditor,
shouldShowOfficeElectronicsHint,
type ItemEditorInitialValues,
type ItemEditorSaveData,
} from "./reimbursement-detail/ReimbursementItemEditor";
import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
focusFirstInvoiceGuardIssue,
+205 -30
查看文件
@@ -13,11 +13,20 @@ import {
transferWorkflowTask,
} from "../api/workflow";
import { toast } from "sonner";
import { fetchInvoiceSignedUrl } from "../api/invoicePool";
import {
downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
} from "../api/reimbursements";
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,
@@ -35,6 +44,13 @@ 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,
@@ -125,6 +141,53 @@ export function WorkflowTaskDetailPage() {
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"),
@@ -141,6 +204,12 @@ export function WorkflowTaskDetailPage() {
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);
@@ -187,6 +256,13 @@ export function WorkflowTaskDetailPage() {
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;
@@ -215,6 +291,31 @@ export function WorkflowTaskDetailPage() {
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">
@@ -249,10 +350,25 @@ export function WorkflowTaskDetailPage() {
}
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
@@ -303,6 +419,8 @@ export function WorkflowTaskDetailPage() {
<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 ? (
@@ -388,38 +506,79 @@ export function WorkflowTaskDetailPage() {
state={{ from: currentTaskSource }}
to={`/reimbursements/${encodeURIComponent(detail.reimbursement.id)}`}
>
查看报销单
打开完整原始表单
</Link>
</Button>
) : null}
</div>
</SectionPanel>
{detail.workflowInstance?.entityType === "reimbursement" &&
detail.workflowInstance.contextData.standardViolations.length ? (
<SectionPanel title="费用标准提示">
<div className="space-y-2">
{detail.workflowInstance.contextData.standardViolations.map((item) => (
<div
key={`${item.itemId}-${item.standardId}`}
className={
item.controlMode === "block"
? "rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
: "rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700"
}
>
<div className="font-medium text-zinc-900">{item.expenseTypeName}</div>
<div>
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit}
{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>
<div>超出 ¥{item.exceededAmount} · {item.standardName}</div>
<div>
{item.controlMode === "block"
? "处理影响:当前单据不应继续通过,请申请人调整金额或补充合规处理依据。"
: "处理影响:可继续审批,但请核对超标原因并在审批意见中记录判断。"}
) : 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>
</div>
))}
) : <p className="mt-2 text-red-700">当前票据检查尚未就绪。</p>}
</div>
</div>
</SectionPanel>
) : null}
@@ -450,7 +609,7 @@ export function WorkflowTaskDetailPage() {
primaryAction={canApprove ? (
<Button
data-testid="workflow-task-approve"
disabled={mutation.isPending}
disabled={mutation.isPending || !reimbursementDecisionReady}
onClick={() => requestWorkflowAction("approve")}
type="button"
>
@@ -463,7 +622,7 @@ export function WorkflowTaskDetailPage() {
<Button
data-testid="workflow-task-return-toggle"
variant="secondary"
disabled={mutation.isPending}
disabled={mutation.isPending || !reimbursementDecisionReady}
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")}
type="button"
>
@@ -474,7 +633,7 @@ export function WorkflowTaskDetailPage() {
<Button
data-testid="workflow-task-reject-toggle"
variant="secondary"
disabled={mutation.isPending}
disabled={mutation.isPending || !reimbursementDecisionReady}
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")}
type="button"
>
@@ -488,6 +647,7 @@ export function WorkflowTaskDetailPage() {
<summary>更多</summary>
<button
data-testid="workflow-task-transfer-menu-item"
disabled={!reimbursementDecisionReady}
type="button"
onClick={() => setActiveSecondaryAction("transfer")}
>
@@ -503,6 +663,19 @@ export function WorkflowTaskDetailPage() {
<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"
@@ -516,7 +689,7 @@ export function WorkflowTaskDetailPage() {
{actionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
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}
@@ -537,7 +710,7 @@ export function WorkflowTaskDetailPage() {
<Button
data-testid="workflow-task-reject"
variant="secondary"
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
disabled={!allowedActions.has("reject") || mutation.isPending || !reimbursementDecisionReady || rejectComment.trim().length < 1}
onClick={() => requestWorkflowAction("reject")}
type="button"
>
@@ -560,7 +733,7 @@ export function WorkflowTaskDetailPage() {
<Button
data-testid="workflow-task-return"
variant="secondary"
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
disabled={!allowedActions.has("return") || mutation.isPending || !reimbursementDecisionReady || returnComment.trim().length < 1}
onClick={() => requestWorkflowAction("return")}
type="button"
>
@@ -610,6 +783,7 @@ export function WorkflowTaskDetailPage() {
disabled={
!allowedActions.has("transfer") ||
mutation.isPending ||
!reimbursementDecisionReady ||
!selectedApprover ||
transferReason.trim().length < 10
}
@@ -659,6 +833,7 @@ export function WorkflowTaskDetailPage() {
await mutation.mutateAsync("transfer");
}}
/>
{downloadReasonDialog}
</>
);
}
@@ -5,18 +5,7 @@ import { fetchExpenseStandards } from "../../api/expenseStandards";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
export const OFFICE_ELECTRONICS_HINT_THRESHOLD = 2000;
export function shouldShowOfficeElectronicsHint(
expenseType: Pick<ExpenseType, "code"> | null | undefined,
amount: string | number,
) {
const numericAmount = Number(amount);
return expenseType?.code === "office_supplies"
&& Number.isFinite(numericAmount)
&& numericAmount >= OFFICE_ELECTRONICS_HINT_THRESHOLD;
}
import { shouldShowOfficeElectronicsHint } from "../../lib/reimbursementPolicyHints";
export type ItemEditorMode = "create" | "edit";