452 行
25 KiB
TypeScript
452 行
25 KiB
TypeScript
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 { ReimbursementReturnReferencePicker } from "../../components/reimbursements/ReimbursementReturnReferencePicker";
|
|
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 } from "../../lib/reimbursementApprovalProjection";
|
|
import { buildReimbursementWorkspaceModel, reimbursementAmountIntegrityMessage } from "../../lib/reimbursementWorkspace";
|
|
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
|
|
import { isWecomWebView } from "../../lib/runtimeContext";
|
|
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../../lib/workflowActionConfirm";
|
|
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 buildReimbursementWorkspaceModel(reimbursementDetailQuery.data, evidence, {
|
|
snapshotInvalid: snapshot?.parseStatus === "invalid",
|
|
snapshotMissing: snapshot?.budgetCheckStatus === "missing",
|
|
});
|
|
}, [evidence, reimbursementDetailQuery.data, snapshot?.budgetCheckStatus, snapshot?.parseStatus]);
|
|
const decisionReady = Boolean(
|
|
online
|
|
&& model
|
|
&& model.amountSummary.integrity === "ready"
|
|
&& snapshot?.parseStatus === "ready",
|
|
);
|
|
const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]);
|
|
const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer");
|
|
const approverQuery = useQuery({
|
|
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(
|
|
model
|
|
? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,暂时不能处理"
|
|
: "完整报销内容或提交时检查尚未就绪,暂时不能处理",
|
|
);
|
|
}
|
|
if (action === "approve") return approveWorkflowTask(taskId);
|
|
if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim());
|
|
if (action === "return") return returnWorkflowTask(taskId, returnComment.trim());
|
|
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
|
|
? "报销审批需联网核对完整报销内容后才能处理。"
|
|
: model
|
|
? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。"
|
|
: "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。");
|
|
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)} />
|
|
<ReimbursementReturnReferencePicker evidence={model.evidence} onReference={appendReturnReference} />
|
|
<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" data-testid="reimbursement-workflow-history">
|
|
{detail.history.length ? detail.history.map((item) => (
|
|
<div className="detail-timeline-item" key={item.id}>
|
|
<div className="font-medium text-zinc-900">{workflowActionLabel(item.action)} · {item.actorName || item.actorEmployeeNo}</div>
|
|
<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,
|
|
};
|
|
|
|
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,
|
|
actionPanel,
|
|
asideContext: timeline,
|
|
}}
|
|
/>
|
|
{dialogs}
|
|
</>
|
|
);
|
|
}
|