文件
hyb-oa/client/src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx
T

423 行
23 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,
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 { workflowActionLabel } from "../../lib/approvalDisplay";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance";
import { formatDisplayDateTime } from "../../lib/displayFormat";
import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection";
import { resolveReimbursementApprovalBlockReason } from "../../lib/reimbursementApprovalReadiness";
import { buildReimbursementWorkspaceModel } from "../../lib/reimbursementWorkspace";
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { isWecomWebView } from "../../lib/runtimeContext";
import { getWorkflowConfirmCopy } from "../../lib/workflowActionConfirm";
import { openFileInBrowser, previewFileInWecom } from "../../lib/wecomFilePreview";
type SecondaryWorkflowAction = "return" | "transfer";
type ReimbursementConfirmAction = "approve" | "return";
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 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 [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<ReimbursementConfirmAction | 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 projectedDecisionBlockReason = resolveReimbursementApprovalBlockReason({
amountIntegrity: model?.amountSummary.integrity ?? null,
detailState: reimbursementDetailQuery.isError ? "error" : reimbursementDetailQuery.data ? "ready" : "loading",
online,
snapshotParseStatus: snapshot?.parseStatus,
});
const decisionBlockReason = projectedDecisionBlockReason
?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。";
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" | "return" | "transfer") => {
if (!decisionReady) {
throw new Error(decisionBlockReason);
}
if (action === "approve") return approveWorkflowTask(taskId);
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: "已通过", return: "已退回", transfer: "已转审" }[action]);
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) void refetchTask();
const message = resolveUserErrorMessage(error, "动作执行失败,请稍后重试");
setActionError(message);
toast.error(message);
},
});
function requestAction(action: ReimbursementConfirmAction) {
if (!decisionReady) {
setActionError(decisionBlockReason);
return;
}
const comment = action === "return" ? returnComment : "";
if (action === "return" && !comment.trim()) {
setActionError("退回修改必须填写意见");
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 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-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">{decisionBlockReason}</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 baseConfirmCopy = confirmAction ? getWorkflowConfirmCopy(confirmAction) : null;
const confirmCopy = confirmAction === "return" && baseConfirmCopy
? { ...baseConfirmCopy, title: "确认退回修改?", confirmLabel: "确认退回修改" }
: baseConfirmCopy;
const actionDisabled = actionMutation.isPending || !decisionReady;
const visibleIssueAnchors = model.issueAnchors.filter((issue) => issue.label !== decisionBlockReason);
const issueAnchors = visibleIssueAnchors.length ? (
<div className="space-y-2" data-testid="reimbursement-workspace-issue-anchors">
<p className="text-sm font-medium text-zinc-900">需要关注</p>
{visibleIssueAnchors.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 decisionBlockNotice = !decisionReady && decisionBlockReason ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" data-testid="reimbursement-approval-block-reason" role="alert">
{decisionBlockReason}
</p>
) : null;
const actionPanelBeforeActions = issueAnchors || decisionBlockNotice ? (
<div className="space-y-3">{issueAnchors}{decisionBlockNotice}</div>
) : null;
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">
{decisionBlockReason}
</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 === "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}
{canTransfer || !online ? <Button className="min-h-11 w-full" data-testid="mobile-approvals-transfer" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="ghost">转审</Button> : null}
</section>
<MobileStickyActionBar contentClassName="grid-cols-2" testId="mobile-approvals-sticky-actions">
{allowedActions.has("return") || !online ? <Button className="min-h-12 w-full" data-testid="mobile-approvals-return" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回修改</Button> : null}
<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="审批决定"
beforeActions={actionPanelBeforeActions}
primaryAction={allowedActions.has("approve") || allowedActions.has("return") ? (
<div className="grid grid-cols-2 gap-2" data-testid="reimbursement-approval-primary-actions">
{allowedActions.has("approve") ? <Button className="min-h-11 w-full" data-testid="workflow-task-approve" disabled={actionDisabled} onClick={() => requestAction("approve")} type="button">通过</Button> : null}
{allowedActions.has("return") ? <Button className="min-h-11 w-full" data-testid="workflow-task-return-toggle" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回修改</Button> : null}
</div>
) : null}
secondaryActions={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={null}
testId="workflow-task-action-panel"
>
{actionError || activeSecondaryAction ? <div className="space-y-3">{commentEditor}</div> : null}
</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}
</>
);
}