文件
hyb-oa/client/src/pages/MobileApprovalsPage.tsx
T
2026-05-14 00:52:59 +08:00

1580 行
61 KiB
TypeScript

import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
import { fetchReimbursements, type ReimbursementListItem } from "../api/reimbursements";
import { isApiError } from "../api/types";
import {
approveWorkflowTask,
fetchWorkflowApprovers,
fetchWorkflowTaskDetail,
fetchWorkflowTasks,
rejectWorkflowTask,
returnWorkflowTask,
transferWorkflowTask,
type WorkflowTaskDetail,
type WorkflowTaskSummary,
} from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Spinner } from "../components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import {
cacheWorkflowTaskDetail,
enqueueWorkflowActionDraft,
getCachedWorkflowTaskDetail,
listCachedWorkflowTaskDetails,
listWorkflowActionDrafts,
removeWorkflowActionDraft,
resolveWorkflowTaskReferenceSnapshot,
updateWorkflowActionDraftStatus,
type WorkflowDraftAction,
type WorkflowDraftQueueItem,
} from "../mobile/workflowActionQueue";
type MobileAction = "approve" | "reject" | "return" | "transfer";
type MobileTaskTab = "pending" | "completed";
type EmployeeProgressTab = "draft" | "reviewing" | "approved" | "returned";
type EmployeeProgressQueryStatus = "draft" | "reviewing" | "approved" | "rejected";
type EntityFilter = "all" | "leave" | "reimbursement" | "purchase_request" | "travel_request";
type ReplayClassification = "failed" | "conflicted";
type VisibleTaskCard = {
id: string;
title: string;
subtitle: string;
status: string;
entityType: string | null;
entityId: string | null;
reference: string | null;
applicantName: string | null;
metric: string | null;
businessStatus: string | null;
allowedActions: MobileAction[];
completedAt: string | null;
isCached: boolean;
detail: WorkflowTaskDetail | null;
};
type EmployeeProgressCard = {
id: string;
title: string;
subtitle: string;
status: string;
to: string;
};
const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
{ value: "all", label: "全部" },
{ value: "leave", label: "请假" },
{ value: "reimbursement", label: "报销" },
{ value: "purchase_request", label: "采购" },
{ value: "travel_request", label: "差旅" },
];
const EMPLOYEE_PROGRESS_TABS: Array<{ value: EmployeeProgressTab; label: string }> = [
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "returned", label: "退回/驳回" },
];
function resolveProgressTab(value: string | null | undefined): EmployeeProgressTab {
if (value === "draft" || value === "reviewing" || value === "approved" || value === "returned") {
return value;
}
return "reviewing";
}
function resolveTaskTab(value: string | null | undefined): MobileTaskTab {
return value === "completed" ? "completed" : "pending";
}
function progressStatusQuery(tab: EmployeeProgressTab): EmployeeProgressQueryStatus {
return tab === "returned" ? "rejected" : tab;
}
function entityLabel(entityType: string | undefined | null) {
if (entityType === "leave") {
return "请假";
}
if (entityType === "reimbursement") {
return "报销";
}
if (entityType === "purchase_request") {
return "采购";
}
if (entityType === "travel_request") {
return "差旅";
}
return entityType || "未知";
}
function workflowStatusLabel(status: string) {
if (status === "pending") {
return "待处理";
}
if (status === "completed") {
return "已处理";
}
if (status === "rejected") {
return "已驳回";
}
if (status === "cancelled") {
return "已取消";
}
if (status === "skipped") {
return "已跳过";
}
return status;
}
function workflowActionLabel(action: string | null | undefined) {
if (!action) {
return "-";
}
const labels: Record<string, string> = {
submit: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
remind: "催办",
overdue_remind: "超时提醒",
cancel: "取消",
skip: "跳过",
};
return labels[action] ?? action;
}
function businessStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = {
draft: "草稿",
pending: "待处理",
reviewing: "审批中",
submitted: "审批中",
approved: "已通过",
rejected: "已驳回",
returned: "已退回",
cancelled: "已取消",
completed: "已完成",
paid: "已付款",
};
return status ? labels[status] ?? status : "-";
}
function publicReference(value: string | null | undefined) {
return compactBusinessReference(value);
}
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "动作执行失败";
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
function classifyReplayError(error: unknown): ReplayClassification {
if (!isApiError(error)) {
return "failed";
}
if (
(error.status === 409 && error.details?.task === "already_processed") ||
error.status === 403 ||
error.status === 404
) {
return "conflicted";
}
return "failed";
}
function queueStatusLabel(status: WorkflowDraftQueueItem["status"]) {
if (status === "queued") {
return "待同步";
}
if (status === "failed") {
return "同步失败";
}
return "存在冲突";
}
function formatDateTime(value: string | null | undefined) {
if (!value) {
return "-";
}
try {
return new Date(value).toLocaleString("zh-CN");
} catch {
return value;
}
}
function normalizeSearchValue(value: string | null | undefined) {
return String(value ?? "").trim().toLowerCase();
}
function isWithinRecentDays(value: string | null | undefined, days: number) {
if (!value) {
return false;
}
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) {
return false;
}
return timestamp >= Date.now() - days * 24 * 60 * 60 * 1000;
}
function matchesEntityFilter(entityType: string | null, filter: EntityFilter) {
return filter === "all" || entityType === filter;
}
function toVisibleRemoteTask(task: WorkflowTaskSummary): VisibleTaskCard {
const entityType = task.workflowInstance?.entityType ?? null;
const summary = task.businessSummary;
const reference = publicReference(summary?.reference ?? task.workflowInstance?.entityId);
const metric = summary?.amount ?? summary?.duration ?? null;
const title = businessPrimaryLabel({
entityType,
reference,
title: summary?.title ?? task.stepName,
fallback: task.stepName,
});
const subtitle = task.status === "completed"
? joinBusinessMeta([`节点:${task.stepName}`, `完成于 ${formatDateTime(task.completedAt)}`])
: joinBusinessMeta([`节点:${task.stepName}`, summary?.applicantName ? `申请人:${summary.applicantName}` : null]);
return {
id: task.id,
title,
subtitle,
status: task.status,
entityType,
entityId: task.workflowInstance?.entityId ?? null,
reference,
applicantName: summary?.applicantName ?? null,
metric,
businessStatus: summary?.businessStatus ?? task.workflowInstance?.currentStatus ?? null,
allowedActions: task.allowedActions,
completedAt: task.completedAt,
isCached: false,
detail: null,
};
}
function matchesSearch(card: VisibleTaskCard, keyword: string) {
if (!keyword) {
return true;
}
const searchValue = normalizeSearchValue(keyword);
const haystack = [
card.title,
entityLabel(card.entityType),
card.id,
card.entityId,
card.reference,
card.applicantName,
card.metric,
card.businessStatus,
]
.map((item) => normalizeSearchValue(item))
.join(" ");
return haystack.includes(searchValue);
}
function toEmployeeProgressCards(input: {
reimbursements: ReimbursementListItem[];
leaveRequests: LeaveRequestItem[];
purchaseRequests: PurchaseRequestSummary[];
}): EmployeeProgressCard[] {
const reimbursementCards = input.reimbursements.map((item) => ({
id: `reimbursement-${item.id}`,
title: businessPrimaryLabel({
entityType: "reimbursement",
reference: item.formNumber,
title: resolveReimbursementDisplayCode(item),
}),
subtitle: joinBusinessMeta([
item.owner?.name ? `报销人:${item.owner.name}` : null,
item.totalAmount ? `金额:${item.totalAmount}` : null,
item.pendingWorkflow?.task?.stepName ? `节点:${item.pendingWorkflow.task.stepName}` : null,
]),
status: item.status,
to: `/mobile/reimbursements/${encodeURIComponent(item.id)}`,
}));
const leaveCards = input.leaveRequests.map((item) => ({
id: `leave-${item.id}`,
title: joinBusinessMeta(["请假", item.leaveType?.name]) || "请假",
subtitle: joinBusinessMeta([
`${item.startDate} 至 ${item.endDate}`,
`${item.requestedDays} 天`,
item.pendingWorkflow?.task?.stepName ? `节点:${item.pendingWorkflow.task.stepName}` : null,
]),
status: item.status,
to: `/mobile/leave/${encodeURIComponent(item.id)}`,
}));
const purchaseCards = input.purchaseRequests.map((item) => ({
id: `purchase-${item.id}`,
title: businessPrimaryLabel({
entityType: "purchase_request",
reference: item.requestNo,
title: resolvePurchaseDisplayCode(item),
}),
subtitle: joinBusinessMeta([
item.applicant?.name ? `申请人:${item.applicant.name}` : null,
item.totalAmount ? `金额:${item.totalAmount}` : null,
item.updatedAt ? `更新:${formatDateTime(item.updatedAt)}` : null,
]),
status: item.status,
to: `/mobile/purchase-requests/${encodeURIComponent(item.id)}`,
}));
return [...reimbursementCards, ...leaveCards, ...purchaseCards].slice(0, 8);
}
export function MobileApprovalsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const routeParams = useParams();
const { me, permissions, accessToken, offlineAccessMode } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkTaskId = routeParams.taskId?.trim() || searchParams.get("taskId")?.trim() || "";
const isPushDetail = Boolean(deepLinkTaskId);
const [selectedTaskId, setSelectedTaskId] = useState(deepLinkTaskId);
const [taskTab, setTaskTab] = useState<MobileTaskTab>(() => resolveTaskTab(searchParams.get("taskTab")));
const [progressTab, setProgressTab] = useState<EmployeeProgressTab>(() =>
resolveProgressTab(searchParams.get("progress")),
);
const [entityFilter, setEntityFilter] = useState<EntityFilter>("all");
const [searchInput, setSearchInput] = useState("");
const deferredSearch = useDeferredValue(searchInput);
const [batchMode, setBatchMode] = useState(false);
const [selectedBatchTaskIds, setSelectedBatchTaskIds] = useState<string[]>([]);
const [batchRejectComment, setBatchRejectComment] = useState("");
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [comment, setComment] = useState("");
const [transferToApproverId, setTransferToApproverId] = useState("");
const [transferReason, setTransferReason] = useState("");
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
const [isOnline, setIsOnline] = useState(true);
const autoSyncRequestedRef = useRef(false);
const ownerEmployeeId = me?.id ?? null;
const canAccessRemoteApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
const canUseRemote = isOnline && Boolean(accessToken) && canAccessRemoteApprovals;
const remoteTaskStatus = taskTab === "completed" ? "completed" : "pending";
const currentMobileSource = `${location.pathname}${location.search}`;
const employeeProgressStatus = progressStatusQuery(progressTab);
const reimbursementProgressQuery = useQuery({
enabled: !canAccessRemoteApprovals,
queryKey: ["mobile", "approvals", "progress", "reimbursements", employeeProgressStatus],
queryFn: () => fetchReimbursements({ scope: "own", status: employeeProgressStatus, page: 1, pageSize: 8 }),
});
const leaveProgressQuery = useQuery({
enabled: !canAccessRemoteApprovals,
queryKey: ["mobile", "approvals", "progress", "leave", employeeProgressStatus],
queryFn: () => fetchLeaveRequests({ scope: "own", status: employeeProgressStatus, page: 1, pageSize: 8 }),
});
const purchaseProgressQuery = useQuery({
enabled: !canAccessRemoteApprovals,
queryKey: ["mobile", "approvals", "progress", "purchase", employeeProgressStatus],
queryFn: () => fetchPurchaseRequests({ mine: true, status: employeeProgressStatus, page: 1, pageSize: 8 }),
});
useEffect(() => {
setSelectedTaskId(deepLinkTaskId);
}, [deepLinkTaskId]);
useEffect(() => {
setProgressTab(resolveProgressTab(searchParams.get("progress")));
}, [searchParams]);
useEffect(() => {
setTaskTab(resolveTaskTab(searchParams.get("taskTab")));
}, [searchParams]);
useEffect(() => {
if (taskTab === "completed") {
setBatchMode(false);
setSelectedBatchTaskIds([]);
setBatchRejectComment("");
}
}, [taskTab]);
function handleTaskSelection(taskId: string) {
navigate(`/mobile/approvals/${encodeURIComponent(taskId)}`, { state: { from: currentMobileSource } });
}
function handleProgressTabChange(value: string) {
const nextTab = value as EmployeeProgressTab;
setProgressTab(nextTab);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("progress", nextTab);
setSearchParams(nextParams, { replace: true });
}
function handleTaskTabChange(value: string) {
const nextTab = resolveTaskTab(value);
setTaskTab(nextTab);
const nextParams = new URLSearchParams(searchParams);
nextParams.set("taskTab", nextTab);
setSearchParams(nextParams, { replace: true });
}
async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-detail", taskId] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, taskId] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "pending-count"] }),
]);
}
const tasksQuery = useQuery({
enabled: canUseRemote,
queryKey: ["mobile", "workflow", "tasks", remoteTaskStatus],
queryFn: () => fetchWorkflowTasks({ status: remoteTaskStatus, page: 1, pageSize: 100 }),
});
const detailQuery = useQuery({
enabled: Boolean(selectedTaskId) && canUseRemote,
queryKey: ["mobile", "workflow", "task-detail", selectedTaskId],
queryFn: () => fetchWorkflowTaskDetail(selectedTaskId),
});
const cachedTasksQuery = useQuery({
enabled: Boolean(ownerEmployeeId),
queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId],
queryFn: () => listCachedWorkflowTaskDetails(ownerEmployeeId),
});
const cachedDetailQuery = useQuery({
enabled: Boolean(selectedTaskId) && Boolean(ownerEmployeeId),
queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, selectedTaskId],
queryFn: () => getCachedWorkflowTaskDetail(selectedTaskId, ownerEmployeeId),
});
const queueQuery = useQuery({
enabled: Boolean(ownerEmployeeId),
queryKey: ["mobile", "workflow", "draft-queue", ownerEmployeeId],
queryFn: () => listWorkflowActionDrafts(ownerEmployeeId),
});
useEffect(() => {
if (!detailQuery.data || !ownerEmployeeId) {
return;
}
void cacheWorkflowTaskDetail(detailQuery.data, ownerEmployeeId).then(async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] }),
queryClient.invalidateQueries({
queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId, detailQuery.data.task.id],
}),
]);
});
}, [detailQuery.data, ownerEmployeeId, queryClient]);
const baseTaskCards = useMemo<VisibleTaskCard[]>(() => {
if (canUseRemote) {
const remoteItems = (tasksQuery.data?.items ?? []).filter((item) =>
taskTab === "completed" ? isWithinRecentDays(item.completedAt, 30) : true,
);
return remoteItems.map(toVisibleRemoteTask);
}
if (taskTab === "completed") {
return [];
}
return (cachedTasksQuery.data ?? [])
.filter((item) => item.detail.task.status === "pending")
.map((item) => ({
id: item.detail.task.id,
title: businessPrimaryLabel({
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType,
reference: item.taskSnapshot.reference,
title: item.taskSnapshot.stepName ?? item.detail.task.stepName,
}),
subtitle: joinBusinessMeta([`节点:${item.taskSnapshot.stepName ?? item.detail.task.stepName}`, `缓存于 ${formatDateTime(item.savedAt)}`]),
status: item.detail.task.status,
entityType: item.taskSnapshot.entityType ?? item.detail.workflowInstance?.entityType ?? null,
entityId: item.taskSnapshot.entityId,
reference: publicReference(item.taskSnapshot.reference),
applicantName: null,
metric: null,
businessStatus: item.detail.workflowInstance?.currentStatus ?? null,
allowedActions: item.detail.allowedActions,
completedAt: item.detail.task.completedAt,
isCached: true,
detail: item.detail,
}));
}, [cachedTasksQuery.data, canUseRemote, taskTab, tasksQuery.data?.items]);
const filteredTaskCards = useMemo(
() =>
baseTaskCards.filter(
(card) => matchesEntityFilter(card.entityType, entityFilter) && matchesSearch(card, deferredSearch),
),
[baseTaskCards, deferredSearch, entityFilter],
);
const pendingTaskMap = useMemo(
() => new Map(filteredTaskCards.map((task) => [task.id, task])),
[filteredTaskCards],
);
const selectedCachedTaskDetail = useMemo(
() => filteredTaskCards.find((task) => task.id === selectedTaskId)?.detail ?? null,
[filteredTaskCards, selectedTaskId],
);
const selectedTaskCard = useMemo(
() => filteredTaskCards.find((task) => task.id === selectedTaskId) ?? null,
[filteredTaskCards, selectedTaskId],
);
const selectedTask = detailQuery.data ?? cachedDetailQuery.data?.detail ?? selectedCachedTaskDetail;
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
const selectedDetailReference = selectedDisplayReference ??
publicReference(selectedTask?.reimbursement?.formNumber) ??
publicReference(selectedTask?.purchaseRequest?.requestNo) ??
publicReference(selectedTask?.travelRequest?.requestNo);
const selectedDetailTitle = selectedTask
? businessPrimaryLabel({
entityType: selectedTask.workflowInstance?.entityType,
reference: selectedDetailReference,
title: selectedTaskCard?.title ?? selectedTask.task.stepName,
})
: "";
const selectedApplicantName = selectedTaskCard?.applicantName ??
selectedTask?.reimbursement?.submitter?.name ??
selectedTask?.leaveRequest?.owner?.name ??
selectedTask?.purchaseRequest?.applicant?.name ??
selectedTask?.travelRequest?.applicant?.name ??
null;
const selectedMetric = selectedTaskCard?.metric ??
selectedTask?.reimbursement?.totalAmount ??
(selectedTask?.leaveRequest ? `${selectedTask.leaveRequest.requestedDays} 天` : null) ??
selectedTask?.purchaseRequest?.totalAmount ??
selectedTask?.travelRequest?.estimatedAmount ??
null;
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
const employeeProgressCards = useMemo(
() =>
toEmployeeProgressCards({
reimbursements: reimbursementProgressQuery.data?.items ?? [],
leaveRequests: leaveProgressQuery.data?.items ?? [],
purchaseRequests: purchaseProgressQuery.data?.items ?? [],
}),
[leaveProgressQuery.data?.items, purchaseProgressQuery.data?.items, reimbursementProgressQuery.data?.items],
);
const employeeProgressLoading =
reimbursementProgressQuery.isLoading || leaveProgressQuery.isLoading || purchaseProgressQuery.isLoading;
const employeeProgressError =
reimbursementProgressQuery.error ?? leaveProgressQuery.error ?? purchaseProgressQuery.error ?? null;
useEffect(() => {
const visibleIds = new Set(filteredTaskCards.map((task) => task.id));
setSelectedBatchTaskIds((current) => current.filter((taskId) => visibleIds.has(taskId)));
}, [filteredTaskCards]);
const canTransfer = Boolean(
canUseRemote &&
selectedTaskAllowsActions &&
selectedTask?.allowedActions.includes("transfer"),
);
const approversQuery = useQuery({
enabled: Boolean(canTransfer),
queryKey: ["mobile", "workflow", "approvers"],
queryFn: () => fetchWorkflowApprovers({ page: 1, pageSize: 100 }),
});
async function refreshDraftQueueCache() {
if (!ownerEmployeeId) {
return [];
}
const drafts = await listWorkflowActionDrafts(ownerEmployeeId);
queryClient.setQueryData(["mobile", "workflow", "draft-queue", ownerEmployeeId], drafts);
return drafts;
}
const currentQueueItems = queueQuery.data ?? [];
const retryableQueueItems = useMemo(
() => currentQueueItems.filter((item) => item.status !== "conflicted"),
[currentQueueItems],
);
async function queueOfflineDraft(action: WorkflowDraftAction) {
try {
if (!selectedTaskId) {
throw new Error("请选择任务");
}
if (!selectedTask) {
throw new Error("当前待办尚未缓存,请联网加载后再操作");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前待办不允许该动作");
}
if (action === "reject" && !comment.trim()) {
throw new Error("驳回必须填写意见");
}
setOfflineDraftSubmitting(true);
await enqueueWorkflowActionDraft({
taskId: selectedTaskId,
action,
comment: action === "reject" ? comment.trim() : null,
ownerEmployeeId,
detail: selectedTask,
});
await refreshDraftQueueCache();
await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] });
setComment("");
setError("");
setMessage(action === "approve" ? "当前离线,通过草稿已加入待同步队列。" : "当前离线,驳回草稿已加入待同步队列。");
toast.success(action === "approve" ? "已加入通过草稿队列" : "已加入驳回草稿队列");
} catch (actionError) {
const nextError = resolveErrorMessage(actionError);
setError(nextError);
setMessage("");
toast.error(nextError);
} finally {
setOfflineDraftSubmitting(false);
}
}
const actionMutation = useMutation({
mutationFn: async (action: MobileAction) => {
if (!canUseRemote) {
throw new Error("当前离线,请使用离线草稿动作");
}
if (!selectedTaskId) {
throw new Error("请选择任务");
}
if (!selectedTask) {
throw new Error("当前待办尚未缓存,请联网加载后再操作");
}
if (!selectedTask.allowedActions.includes(action)) {
throw new Error("当前待办不允许该动作");
}
if (action === "approve") {
await approveWorkflowTask(selectedTaskId);
return { action };
}
if (action === "reject") {
if (!comment.trim()) {
throw new Error("驳回必须填写意见");
}
await rejectWorkflowTask(selectedTaskId, comment.trim());
return { action };
}
if (action === "return") {
if (!comment.trim()) {
throw new Error("退回必须填写意见");
}
await returnWorkflowTask(selectedTaskId, comment.trim());
return { action };
}
if (!transferToApproverId) {
throw new Error("请选择转审目标审批人");
}
if (transferReason.trim().length < 10) {
throw new Error("转审理由至少 10 个字");
}
await transferWorkflowTask(selectedTaskId, transferToApproverId, transferReason.trim());
return { action };
},
onSuccess: async () => {
await invalidateWorkflowQueries(selectedTaskId);
setComment("");
setTransferReason("");
setTransferToApproverId("");
setError("");
setMessage("");
navigate("/mobile/approvals", { replace: true });
toast.success("操作成功");
},
onError: (actionError) => {
if (isAlreadyProcessedTaskError(actionError)) {
void invalidateWorkflowQueries(selectedTaskId);
}
const nextError = resolveErrorMessage(actionError);
setError(nextError);
setMessage("");
toast.error(nextError);
},
});
const batchActionMutation = useMutation({
mutationFn: async (input: { action: WorkflowDraftAction; taskIds: string[]; comment?: string }) => {
if (!input.taskIds.length) {
throw new Error("请先选择至少一条待办");
}
if (input.action === "reject" && !input.comment?.trim()) {
throw new Error("批量驳回必须填写共享意见");
}
let succeeded = 0;
let failed = 0;
let skipped = 0;
const failures: string[] = [];
for (const taskId of input.taskIds) {
const taskCard = pendingTaskMap.get(taskId);
if (!taskCard) {
skipped += 1;
failures.push(`${taskId}: 当前筛选结果中找不到该待办`);
continue;
}
if (!taskCard.allowedActions.includes(input.action)) {
skipped += 1;
failures.push(`${taskId}: 当前待办不允许${input.action === "approve" ? "通过" : "驳回"}`);
continue;
}
try {
if (canUseRemote) {
if (input.action === "approve") {
await approveWorkflowTask(taskId);
} else {
await rejectWorkflowTask(taskId, input.comment!.trim());
}
} else {
if (!taskCard.detail) {
throw new Error("当前待办尚未缓存,请联网后重试");
}
await enqueueWorkflowActionDraft({
taskId,
action: input.action,
comment: input.action === "reject" ? input.comment?.trim() ?? null : null,
ownerEmployeeId,
detail: taskCard.detail,
});
}
succeeded += 1;
} catch (batchError) {
failed += 1;
failures.push(`${taskId}: ${resolveErrorMessage(batchError)}`);
}
}
return { ...input, succeeded, failed, skipped, failures };
},
onSuccess: async (result) => {
if (canUseRemote) {
await invalidateWorkflowQueries(selectedTaskId);
} else {
await refreshDraftQueueCache();
await queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "task-cache", ownerEmployeeId] });
}
const actionLabel = result.action === "approve" ? "通过" : "驳回";
const summary = canUseRemote
? `批量${actionLabel}完成:成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`
: `离线批量${actionLabel}已入队:成功 ${result.succeeded},失败 ${result.failed},跳过 ${result.skipped}`;
setBatchMode(false);
setSelectedBatchTaskIds([]);
setBatchRejectComment("");
setComment("");
setError(result.failures.length ? result.failures.slice(0, 3).join(";") : "");
setMessage(summary);
if (canUseRemote && result.taskIds.includes(selectedTaskId)) {
navigate("/mobile/approvals", { replace: true });
}
if (result.failed > 0) {
toast.error(summary);
return;
}
toast.success(summary);
},
onError: (batchError) => {
const nextError = resolveErrorMessage(batchError);
setError(nextError);
setMessage("");
toast.error(nextError);
},
});
const isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting;
const syncMutation = useMutation({
mutationFn: async (items: WorkflowDraftQueueItem[]) => {
let succeeded = 0;
let failed = 0;
let conflicted = 0;
for (const item of items.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))) {
try {
if (item.action === "approve") {
await approveWorkflowTask(item.taskId);
} else {
await rejectWorkflowTask(item.taskId, item.comment ?? "");
}
await removeWorkflowActionDraft(item.taskId, ownerEmployeeId);
succeeded += 1;
} catch (syncError) {
const classification = classifyReplayError(syncError);
const lastError = resolveErrorMessage(syncError);
await updateWorkflowActionDraftStatus({
taskId: item.taskId,
ownerEmployeeId,
status: classification,
lastError,
});
if (classification === "conflicted") {
conflicted += 1;
} else {
failed += 1;
}
}
}
return { succeeded, failed, conflicted };
},
onSuccess: async (result) => {
await invalidateWorkflowQueries(selectedTaskId);
setError("");
setMessage(`草稿同步完成:成功 ${result.succeeded},失败 ${result.failed},冲突 ${result.conflicted}`);
if (result.succeeded || result.failed || result.conflicted) {
toast.success(`草稿同步完成:成功 ${result.succeeded},失败 ${result.failed},冲突 ${result.conflicted}`);
}
},
onError: (syncError) => {
const nextError = resolveErrorMessage(syncError);
setError(nextError);
setMessage("");
toast.error(nextError);
},
});
useEffect(() => {
const onOnline = () => {
setIsOnline(true);
autoSyncRequestedRef.current = true;
};
const onOffline = () => {
setIsOnline(false);
};
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, []);
useEffect(() => {
if (accessToken) {
setIsOnline(true);
}
}, [accessToken]);
useEffect(() => {
if (!autoSyncRequestedRef.current || !canUseRemote || syncMutation.isPending) {
return;
}
autoSyncRequestedRef.current = false;
if (retryableQueueItems.length) {
syncMutation.mutate(retryableQueueItems);
}
}, [canUseRemote, retryableQueueItems, syncMutation]);
function toggleBatchTask(taskId: string) {
setSelectedBatchTaskIds((current) =>
current.includes(taskId) ? current.filter((id) => id !== taskId) : [...current, taskId],
);
}
function toggleSelectAllVisibleTasks() {
const visibleIds = filteredTaskCards.map((task) => task.id);
const allSelected = visibleIds.length > 0 && visibleIds.every((taskId) => selectedBatchTaskIds.includes(taskId));
if (allSelected) {
setSelectedBatchTaskIds([]);
return;
}
setSelectedBatchTaskIds(visibleIds);
}
function handleBatchAction(action: WorkflowDraftAction) {
batchActionMutation.mutate({
action,
taskIds: selectedBatchTaskIds,
comment: action === "reject" ? batchRejectComment : undefined,
});
}
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
const allVisibleSelected =
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
return (
<PageScaffold className="space-y-4">
{isPushDetail ? (
<MobileDetailHeader
title="审批详情"
subtitle={selectedDetailTitle || "查看审批任务"}
status={
selectedTask ? (
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
) : null
}
fallbackTo={`/mobile/approvals?taskTab=${taskTab}`}
testId="mobile-approvals-detail-header"
/>
) : (
<PageHeader
title={canAccessRemoteApprovals ? "审批" : "审批进度"}
subtitle={canAccessRemoteApprovals ? "待我审批和已处理。" : "查看我发起的申请进度。"}
/>
)}
{!isOnline && canAccessRemoteApprovals ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
当前离线{offlineAccessMode ? ",已使用本地会话快照进入移动审批壳。" : "。"}仅支持查看已缓存待办,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。
</p>
) : null}
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
{message ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
) : null}
{!isPushDetail && !canAccessRemoteApprovals ? (
<section className="mobile-card mobile-page-stack" data-testid="mobile-approvals-progress">
<div className="mobile-section-header">
<div className="min-w-0">
<h3 className="mobile-section-title">我发起的进度</h3>
<p className="mobile-list-card__subtitle mt-1">报销、请假、采购进度</p>
</div>
<span className="badge-neutral">{employeeProgressCards.length} 条</span>
</div>
<Tabs value={progressTab} onValueChange={handleProgressTabChange}>
<TabsList className="grid h-auto w-full grid-cols-4">
{EMPLOYEE_PROGRESS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-progress-tab-${item.value}`}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{employeeProgressLoading ? <div className="mobile-empty-state">加载中...</div> : null}
{employeeProgressError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(employeeProgressError)}
</p>
) : null}
{!employeeProgressLoading && !employeeProgressError && !employeeProgressCards.length ? (
<div className="mobile-empty-state">暂无申请进度</div>
) : null}
<div className="mobile-page-stack">
{employeeProgressCards.map((item) => (
<Link
className="mobile-list-card block"
data-testid="mobile-approval-progress-card"
key={item.id}
to={item.to}
state={{ from: currentMobileSource }}
>
<div className="mobile-list-card__header">
<div className="min-w-0">
<div className="mobile-list-card__title">{item.title}</div>
{item.subtitle ? <div className="mobile-list-card__subtitle mt-1">{item.subtitle}</div> : null}
</div>
<span className="mobile-status-slot">
<StatusBadge status={item.status} />
</span>
</div>
</Link>
))}
</div>
</section>
) : null}
{!isPushDetail && canAccessRemoteApprovals ? (
<section className="mobile-card mobile-page-stack" data-testid="mobile-approvals-list">
<div className="mobile-section-header">
<div className="min-w-0">
<h3 className="mobile-section-title">{listTitle}</h3>
<p className="mobile-list-card__subtitle mt-1">{filteredTaskCards.length} 条</p>
</div>
<span className={`mobile-section-meta ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
</div>
<Tabs value={taskTab} onValueChange={handleTaskTabChange}>
<TabsList className="flex w-full justify-start gap-1">
<TabsTrigger value="pending">待我审批</TabsTrigger>
<TabsTrigger value="completed" disabled={!canUseRemote}>已处理</TabsTrigger>
</TabsList>
</Tabs>
<div className="space-y-3">
<Input
data-testid="mobile-approvals-search"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="搜索审批"
/>
<details className="mobile-subcard bg-zinc-50/70 text-sm">
<summary className="cursor-pointer select-none font-medium text-zinc-700">
筛选与批量
</summary>
<div className="mt-3 space-y-3">
<Tabs value={entityFilter} onValueChange={(value) => setEntityFilter(value as EntityFilter)}>
<TabsList className="flex w-full flex-wrap justify-start gap-1">
{ENTITY_FILTER_OPTIONS.map((option) => (
<TabsTrigger key={option.value} value={option.value}>
{option.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{taskTab === "pending" ? (
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center">
<Button
type="button"
size="sm"
variant={batchMode ? "default" : "secondary"}
className="min-h-11 w-full sm:w-auto"
disabled={!filteredTaskCards.length}
onClick={() => {
setBatchMode((current) => !current);
setSelectedBatchTaskIds([]);
setBatchRejectComment("");
}}
>
{batchMode ? "退出批量" : "批量"}
</Button>
{batchMode ? (
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={!filteredTaskCards.length}
onClick={toggleSelectAllVisibleTasks}
>
{allVisibleSelected ? "清空选择" : "全选当前"}
</Button>
) : null}
<span className="text-sm text-zinc-500 sm:ml-1">
当前 {filteredTaskCards.length} 条
{batchMode ? `,已选 ${selectedBatchTaskIds.length}` : ""}
</span>
</div>
) : (
<p className="text-sm text-zinc-500">仅显示最近 30 天。</p>
)}
</div>
</details>
</div>
{batchMode ? (
<div className="mobile-subcard bg-zinc-50 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-zinc-700">已选 {selectedBatchTaskIds.length} 条</div>
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
onClick={() => handleBatchAction("approve")}
>
{canUseRemote ? "批量通过" : "保存通过草稿"}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
onClick={() => handleBatchAction("reject")}
>
{canUseRemote ? "批量驳回" : "保存驳回草稿"}
</Button>
</div>
</div>
<Textarea
className="mt-3"
rows={3}
value={batchRejectComment}
onChange={(event) => setBatchRejectComment(event.target.value)}
placeholder="驳回意见。"
/>
</div>
) : null}
{canUseRemote && tasksQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Spinner size="sm" />
加载中...
</div>
) : null}
{canUseRemote && tasksQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(tasksQuery.error)}
</p>
) : null}
{!canUseRemote && taskTab === "completed" ? (
<p className="text-sm text-zinc-500">离线模式仅缓存待处理任务详情;已处理历史需联网查看。</p>
) : null}
{!filteredTaskCards.length && !(canUseRemote && tasksQuery.isLoading) ? (
<p className="text-sm text-zinc-500">
{taskTab === "completed"
? "最近 30 天暂无符合筛选条件的已处理任务。"
: !canAccessRemoteApprovals && isOnline
? "暂无待我审批;收到企业微信审批消息后可从这里查看。"
: canUseRemote
? "暂无符合筛选条件的审批任务。"
: "暂无符合筛选条件的已缓存审批。"}
</p>
) : null}
<div className="mobile-page-stack">
{filteredTaskCards.map((task) => {
const isSelected = selectedTaskId === task.id;
const isBatchSelected = selectedBatchTaskIds.includes(task.id);
if (batchMode && taskTab === "pending") {
return (
<div
key={task.id}
className={`mobile-list-card grid grid-cols-[auto_minmax(0,1fr)] gap-3 ${
isBatchSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
}`}
>
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-zinc-300"
checked={isBatchSelected}
onChange={() => toggleBatchTask(task.id)}
aria-label={`选择${entityLabel(task.entityType)}待办`}
/>
<button
type="button"
className="min-h-16 min-w-0 flex-1 text-left"
data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => toggleBatchTask(task.id)}
>
<div className="mobile-list-card__header">
<div className="mobile-list-card__title">{task.title}</div>
<span className="mobile-status-slot">
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
</span>
</div>
<div className="mobile-list-card__subtitle mt-1">{task.subtitle}</div>
<div className="mt-1 min-w-0 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
</div>
);
}
return (
<button
key={task.id}
className={`mobile-list-card min-h-20 w-full text-left transition-colors ${
isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
}`}
data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => handleTaskSelection(task.id)}
type="button"
>
<div className="mobile-list-card__header">
<div className="mobile-list-card__title">{task.title}</div>
<span className="mobile-status-slot">
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
</span>
</div>
<div className="mobile-list-card__subtitle mt-1">{task.subtitle}</div>
<div className="mt-1 min-w-0 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
</button>
);
})}
</div>
</section>
) : null}
{isPushDetail ? (
<section className="mobile-card mobile-page-stack scroll-mt-4" data-testid="mobile-approvals-detail">
<h3 className="mobile-section-title">处理</h3>
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
{selectedTaskId && canUseRemote && detailQuery.isLoading ? (
<p className="text-sm text-zinc-500">加载任务详情中...</p>
) : null}
{selectedTaskId && canUseRemote && detailQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(detailQuery.error)}
</p>
) : null}
{isOfflineUncachedDeepLink ? (
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-700">
离线未缓存该待办,请联网后重试。
</p>
) : null}
{selectedTask ? (
<>
<div className="mobile-list-card text-sm">
<div className="mobile-list-card__header">
<div className="min-w-0">
<div className="mobile-list-card__title">{selectedDetailTitle}</div>
<div className="mobile-list-card__subtitle mt-1">{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}</div>
</div>
<span className="mobile-status-slot">
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
</span>
</div>
<div className="mobile-field-list mt-3">
<div className="mobile-field-row">
<div className="mobile-field-label">类型</div>
<div className="mobile-field-value">{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">业务状态</div>
<div className="mobile-field-value">{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}</div>
</div>
{selectedApplicantName ? (
<div className="mobile-field-row">
<div className="mobile-field-label">申请人</div>
<div className="mobile-field-value">{selectedApplicantName}</div>
</div>
) : null}
{selectedMetric ? (
<div className="mobile-field-row">
<div className="mobile-field-label">金额/天数</div>
<div className="mobile-field-value">{selectedMetric}</div>
</div>
) : null}
{selectedTask.task.completedAt ? (
<div className="mobile-field-row">
<div className="mobile-field-label">完成时间</div>
<div className="mobile-field-value">{formatDateTime(selectedTask.task.completedAt)}</div>
</div>
) : null}
</div>
{selectedTask.reimbursement ? (
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
</div>
) : null}
{selectedTask.leaveRequest ? (
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
</div>
) : null}
{selectedTask.purchaseRequest ? (
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`供应商:${selectedTask.purchaseRequest.supplier?.name ?? "-"}`, selectedTask.purchaseRequest.title])}
</div>
) : null}
{selectedTask.travelRequest ? (
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{joinBusinessMeta([`出差类型:${selectedTask.travelRequest.tripType === "international" ? "国际" : "国内"}`, selectedTask.travelRequest.purpose])}
</div>
) : null}
</div>
<div className="mobile-page-stack" data-testid="mobile-approvals-history">
<div className="mobile-section-header">
<h4 className="mobile-section-title">处理历史</h4>
<span className="mobile-section-meta">{selectedTask.history.length} 条</span>
</div>
{selectedTask.history.length ? (
selectedTask.history.map((item) => (
<article className="mobile-list-card text-sm" data-testid="mobile-approvals-history-item" key={item.id}>
<div className="font-medium text-zinc-900">
{workflowActionLabel(item.action)} · {item.actorName || item.actorEmployeeNo || item.actorId}
</div>
<div className="mt-1 text-zinc-600">{item.comment || "-"}</div>
<div className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
</article>
))
) : (
<p className="text-sm text-zinc-500">暂无历史记录</p>
)}
</div>
{!selectedTaskAllowsActions ? (
<p className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
当前任务已处理,仅供查看,不支持再次审批。
</p>
) : (
<>
<Textarea
data-testid="mobile-approvals-comment"
onChange={(event) => setComment(event.target.value)}
placeholder="意见(驳回/退回必填)"
rows={3}
value={comment}
/>
{!canUseRemote ? (
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
) : null}
<div className="mobile-sticky-action-grid">
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-approve"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
onClick={() => {
if (canUseRemote) {
actionMutation.mutate("approve");
return;
}
void queueOfflineDraft("approve");
}}
type="button"
>
{canUseRemote ? "通过" : "保存通过草稿"}
</Button>
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-reject"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
onClick={() => {
if (canUseRemote) {
actionMutation.mutate("reject");
return;
}
void queueOfflineDraft("reject");
}}
type="button"
variant="secondary"
>
{canUseRemote ? "驳回" : "保存驳回草稿"}
</Button>
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-return"
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
onClick={() => actionMutation.mutate("return")}
type="button"
variant="secondary"
>
退回
</Button>
</div>
{canTransfer ? (
<div className="mobile-list-card space-y-3 text-sm">
<p className="font-medium text-zinc-800">转审</p>
<select
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
data-testid="mobile-approvals-transfer-to"
onChange={(event) => setTransferToApproverId(event.target.value)}
value={transferToApproverId}
>
<option value="">请选择目标审批人</option>
{(approversQuery.data?.items ?? []).map((approver) => (
<option key={approver.id} value={approver.id}>
{approver.name} ({approver.employeeNo})
</option>
))}
</select>
<Textarea
data-testid="mobile-approvals-transfer-reason"
onChange={(event) => setTransferReason(event.target.value)}
placeholder="转审理由(至少10字)"
rows={2}
value={transferReason}
/>
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-transfer"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
onClick={() => actionMutation.mutate("transfer")}
type="button"
variant="secondary"
>
提交转审
</Button>
</div>
) : null}
</>
)}
</>
) : null}
</section>
) : null}
{!isPushDetail && shouldShowDraftQueue ? (
<section className="mobile-card mobile-page-stack" data-testid="mobile-approvals-queue">
<div className="mobile-section-header">
<h3 className="mobile-section-title">离线草稿</h3>
<span className="mobile-section-meta">{currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}</span>
</div>
{currentQueueItems.length ? (
<div className="grid gap-2 sm:flex sm:flex-wrap">
<Button
className="min-h-11 w-full sm:w-auto"
data-testid="mobile-approvals-sync-queue"
disabled={!canUseRemote || syncMutation.isPending || !retryableQueueItems.length}
onClick={() => syncMutation.mutate(retryableQueueItems)}
type="button"
variant="secondary"
>
{syncMutation.isPending ? "同步中..." : `同步草稿 (${retryableQueueItems.length})`}
</Button>
</div>
) : null}
{!currentQueueItems.length ? <p className="text-sm text-zinc-500">暂无待同步草稿。</p> : null}
{currentQueueItems.map((item) => (
<div key={item.id} className="mobile-list-card text-sm">
<div className="mobile-list-card__title">
{publicReference(item.taskSnapshot.reference) ?? entityLabel(item.taskSnapshot.entityType)} · {item.action === "approve" ? "通过" : "驳回"}
</div>
<div className="mobile-field-list mt-2">
<div className="mobile-field-row">
<div className="mobile-field-label">业务</div>
<div className="mobile-field-value">{entityLabel(item.taskSnapshot.entityType)} · 步骤:{item.taskSnapshot.stepName ?? "-"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">状态</div>
<div className="mobile-field-value">{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">意见</div>
<div className="mobile-field-value">{item.comment ?? "-"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">最近错误</div>
<div className="mobile-field-value">{item.lastError ?? "-"}</div>
</div>
</div>
<div className="mt-2 grid gap-2 sm:flex sm:flex-wrap">
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
onClick={() => handleTaskSelection(item.taskId)}
>
查看任务
</Button>
{item.status === "failed" ? (
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={syncMutation.isPending}
onClick={async () => {
await updateWorkflowActionDraftStatus({
taskId: item.taskId,
ownerEmployeeId,
status: "queued",
lastError: null,
});
await refreshDraftQueueCache();
if (canUseRemote) {
syncMutation.mutate([{ ...item, status: "queued", lastError: null }]);
}
}}
>
重试
</Button>
) : null}
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11 w-full sm:w-auto"
onClick={async () => {
await removeWorkflowActionDraft(item.taskId, ownerEmployeeId);
await refreshDraftQueueCache();
}}
>
丢弃草稿
</Button>
</div>
</div>
))}
</section>
) : null}
</PageScaffold>
);
}