1655 行
70 KiB
TypeScript
1655 行
70 KiB
TypeScript
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
|
|
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 { fetchLeaveRequests, type LeaveRequestItem } from "../api/leave";
|
|
import { fetchPurchaseRequests, type PurchaseRequestSummary } from "../api/purchaseRequests";
|
|
import {
|
|
downloadReimbursementAttachment,
|
|
fetchReimbursementAttachmentPreviewUrl,
|
|
fetchReimbursementDetail,
|
|
fetchReimbursementMaterialPreviewBundle,
|
|
fetchReimbursements,
|
|
type ReimbursementListItem,
|
|
} from "../api/reimbursements";
|
|
import { fetchInvoiceSignedUrl } from "../api/invoicePool";
|
|
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 { ConfirmDialog } from "../components/common/ConfirmDialog";
|
|
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
|
import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
import { StatusBadge } from "../components/common/StatusBadge";
|
|
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
|
import { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
|
|
import { Button } from "../components/ui/button";
|
|
import { Input } from "../components/ui/input";
|
|
import { Label } from "../components/ui/label";
|
|
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, workflowActionLabel } from "../lib/approvalDisplay";
|
|
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
|
|
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
|
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
|
import {
|
|
buildReimbursementApprovalEvidence,
|
|
buildReimbursementReturnReference,
|
|
} from "../lib/reimbursementApprovalProjection";
|
|
import { isWecomWebView } from "../lib/runtimeContext";
|
|
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
|
import { cn } from "../lib/utils";
|
|
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
|
|
import {
|
|
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" | "attendance_correction" | "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: "attendance_correction", 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 === "attendance_correction") {
|
|
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 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) {
|
|
return resolveUserErrorMessage(error, "动作执行失败");
|
|
}
|
|
|
|
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 [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 [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
|
const [showSecondaryActions, setShowSecondaryActions] = 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 confirmCopy = confirmAction
|
|
? getWorkflowConfirmCopy(confirmAction, { offlineDraft: !canUseRemote && confirmAction !== "return" })
|
|
: null;
|
|
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]);
|
|
|
|
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 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 mobileReimbursementId = selectedTask?.reimbursement?.id ?? "";
|
|
const reimbursementDetailQuery = useQuery({
|
|
enabled: Boolean(mobileReimbursementId) && canUseRemote,
|
|
queryKey: ["reimbursements", mobileReimbursementId],
|
|
queryFn: () => fetchReimbursementDetail(mobileReimbursementId),
|
|
});
|
|
const reimbursementEvidence = useMemo(() => {
|
|
const snapshot = selectedTask?.workflowInstance?.contextData;
|
|
if (!reimbursementDetailQuery.data || !snapshot) return null;
|
|
return buildReimbursementApprovalEvidence(reimbursementDetailQuery.data, snapshot);
|
|
}, [reimbursementDetailQuery.data, selectedTask?.workflowInstance?.contextData]);
|
|
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
|
const openInvoiceMutation = useMutation({
|
|
mutationFn: async (invoiceId: string) => {
|
|
const result = await fetchInvoiceSignedUrl(invoiceId);
|
|
const openedInWecom = await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
|
if (!openedInWecom) openFileInBrowser(result.signedUrl);
|
|
},
|
|
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开发票失败,请重试")),
|
|
});
|
|
const openAttachmentMutation = useMutation({
|
|
mutationFn: async ({ attachment, reason }: {
|
|
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
|
|
reason?: string;
|
|
}) => {
|
|
if (isWecomWebView()) {
|
|
const result = await fetchReimbursementAttachmentPreviewUrl(mobileReimbursementId, attachment.id, reason);
|
|
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
|
|
return null;
|
|
}
|
|
return downloadReimbursementAttachment(mobileReimbursementId, attachment.id, reason);
|
|
},
|
|
onSuccess: (result) => {
|
|
if (!result) return;
|
|
const url = URL.createObjectURL(result.blob);
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
},
|
|
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开附件失败,请重试")),
|
|
});
|
|
const materialPreviewMutation = useMutation({
|
|
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(mobileReimbursementId, reason),
|
|
onSuccess: (bundle) => openFileInBrowser(bundle.signedUrl),
|
|
onError: (actionError) => toast.error(resolveUserErrorMessage(actionError, "打开完整材料失败,可逐份查看原件")),
|
|
});
|
|
const selectedReference = selectedTask ? resolveWorkflowTaskReferenceSnapshot(selectedTask) : null;
|
|
const selectedDisplayReference = selectedTaskCard?.reference ?? publicReference(selectedReference?.reference);
|
|
const selectedDetailReference = selectedDisplayReference ??
|
|
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?.attendanceCorrection?.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?.attendanceCorrection ? selectedTask.attendanceCorrection.workDate : null) ??
|
|
selectedTask?.purchaseRequest?.totalAmount ??
|
|
selectedTask?.travelRequest?.estimatedAmount ??
|
|
null;
|
|
const selectedTaskAllowsActions = Boolean(taskTab === "pending" && selectedTask?.task.status === "pending");
|
|
const isSelectedReimbursement = selectedTask?.workflowInstance?.entityType === "reimbursement";
|
|
const reimbursementDecisionReady = !isSelectedReimbursement || Boolean(
|
|
canUseRemote && reimbursementEvidence && selectedTask?.workflowInstance?.contextData.parseStatus === "ready",
|
|
);
|
|
const isOfflineUncachedDeepLink = Boolean(selectedTaskId) && !selectedTask && !canUseRemote;
|
|
const employeeProgressCards = useMemo(
|
|
() =>
|
|
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;
|
|
|
|
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 }),
|
|
});
|
|
|
|
function requestMobileWorkflowAction(action: WorkflowConfirmAction) {
|
|
if (isSelectedReimbursement && !reimbursementDecisionReady) {
|
|
setError("报销审批需联网加载完整明细、票据和提交时检查后才能处理。");
|
|
return;
|
|
}
|
|
if (!canUseRemote && action === "return") {
|
|
setError("当前离线不支持退回,请联网后再处理。");
|
|
return;
|
|
}
|
|
|
|
if (action === "reject" && !comment.trim()) {
|
|
setError("驳回必须填写意见");
|
|
return;
|
|
}
|
|
|
|
if (action === "return" && !comment.trim()) {
|
|
setError("退回必须填写意见");
|
|
return;
|
|
}
|
|
|
|
setError("");
|
|
setConfirmAction(action);
|
|
}
|
|
|
|
function appendReturnReference(reference: string) {
|
|
if (!reference) return;
|
|
setComment((current) => current.includes(reference) ? current : `${current.trim()}\n${reference}`.trim());
|
|
setShowSecondaryActions(true);
|
|
setError("");
|
|
}
|
|
|
|
async function handleOpenAttachment(attachmentId: string) {
|
|
const attachment = reimbursementDetailQuery.data?.attachments.find((item) => item.id === attachmentId);
|
|
if (!attachment) {
|
|
toast.error("附件信息已变化,请刷新后重试");
|
|
return;
|
|
}
|
|
const reason = await requestDownloadReason(attachment);
|
|
if (reason === null) return;
|
|
openAttachmentMutation.mutate({ attachment, reason });
|
|
}
|
|
|
|
async function handleOpenAllMaterials() {
|
|
const governedAttachment = reimbursementDetailQuery.data?.attachments.find(attachmentRequiresDownloadReason);
|
|
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
|
|
if (reason === null) return;
|
|
materialPreviewMutation.mutate(reason);
|
|
}
|
|
|
|
async function refreshDraftQueueCache() {
|
|
if (!ownerEmployeeId) {
|
|
return [];
|
|
}
|
|
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.workflowInstance?.entityType === "reimbursement") {
|
|
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.workflowInstance?.entityType === "reimbursement" && !reimbursementDecisionReady) {
|
|
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 isActionSubmitting = actionMutation.isPending || offlineDraftSubmitting;
|
|
const transferDisabledReason = !transferToApproverId
|
|
? "请先选择目标审批人。"
|
|
: transferReason.trim().length < 10
|
|
? `请填写转审理由,至少 10 个字(还差 ${10 - transferReason.trim().length} 个字)。`
|
|
: "转审理由已满足要求。";
|
|
|
|
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]);
|
|
|
|
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
|
|
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
|
|
const showMobileApprovalActions = Boolean(isPushDetail && selectedTask && selectedTaskAllowsActions);
|
|
const approvalFocusText = taskTab === "pending"
|
|
? filteredTaskCards.length
|
|
? `当前有 ${filteredTaskCards.length} 条待处理,可优先查看金额、时效或退回风险最高的事项。`
|
|
: "暂无待处理审批,收到企业微信或站内通知后可从这里直达详情。"
|
|
: "最近 30 天已处理审批用于复核,不支持再次审批。";
|
|
const selectedActionText = selectedTask
|
|
? selectedTaskAllowsActions
|
|
? isSelectedReimbursement && !reimbursementDecisionReady
|
|
? "报销审批需联网加载完整明细、票据和提交时检查;内容就绪前不能通过、退回、驳回或保存草稿。"
|
|
: canUseRemote
|
|
? "核对摘要和历史后,可在底部通过、驳回或退回。"
|
|
: "当前离线,仅支持保存通过或驳回草稿,联网后再同步。"
|
|
: "当前任务已处理,本页只展示业务摘要和处理历史。"
|
|
: "";
|
|
|
|
return (
|
|
<>
|
|
<PageScaffold
|
|
className={cn(
|
|
"space-y-4",
|
|
showMobileApprovalActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : "",
|
|
)}
|
|
>
|
|
{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 ? ",已使用本地会话快照进入移动审批壳。" : "。"}
|
|
{isSelectedReimbursement
|
|
? "当前报销任务只显示摘要,不能保存审批草稿;请联网核对完整内容。"
|
|
: "仅支持查看已缓存审批任务,并保存通过 / 驳回草稿;联网同步成功后才会正式提交。"}
|
|
</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 mobile-list-card__header--approval">
|
|
<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 mobile-status-slot--approval">
|
|
<StatusBadge className="mobile-status-badge" status={item.status} />
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{!isPushDetail && canAccessRemoteApprovals ? (
|
|
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-approvals-focus">
|
|
<div className="mobile-section-header">
|
|
<div className="min-w-0">
|
|
<h3 className="mobile-section-title">当前办理焦点</h3>
|
|
<p className="mobile-list-card__subtitle mt-1">{approvalFocusText}</p>
|
|
</div>
|
|
<span className={`mobile-section-meta ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>
|
|
{isOnline ? "在线" : "离线"}
|
|
</span>
|
|
</div>
|
|
<div className="mobile-info-grid text-sm">
|
|
<div>
|
|
<div className="text-xs text-zinc-500">当前筛选</div>
|
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{filteredTaskCards.length}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">离线草稿</div>
|
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{currentQueueItems.length}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">处理方式</div>
|
|
<div className="mt-1 text-sm font-semibold text-zinc-900">逐条核对</div>
|
|
</div>
|
|
</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>
|
|
|
|
<div className="mobile-command-surface">
|
|
<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>
|
|
|
|
<Input
|
|
data-testid="mobile-approvals-search"
|
|
value={searchInput}
|
|
onChange={(event) => setSearchInput(event.target.value)}
|
|
placeholder="搜索审批"
|
|
/>
|
|
|
|
<details className="mobile-subcard bg-white 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>
|
|
|
|
<p className="text-sm text-zinc-500">
|
|
{taskTab === "pending" ? `当前 ${filteredTaskCards.length} 条` : "仅显示最近 30 天。"}
|
|
</p>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
{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;
|
|
|
|
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 mobile-list-card__header--approval">
|
|
<div className="mobile-list-card__title">{task.title}</div>
|
|
<span className="mobile-status-slot mobile-status-slot--approval">
|
|
<StatusBadge
|
|
className="mobile-status-badge"
|
|
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-focus-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-subcard bg-white text-sm" data-testid="mobile-approvals-detail-focus">
|
|
<div className="text-xs font-semibold text-zinc-500">下一步</div>
|
|
<div className="mt-1 leading-6 text-zinc-700">{selectedActionText}</div>
|
|
</div>
|
|
|
|
<div className="mobile-list-card text-sm">
|
|
<div className="mobile-list-card__header mobile-list-card__header--approval">
|
|
<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 mobile-status-slot--approval">
|
|
<StatusBadge
|
|
className="mobile-status-badge"
|
|
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 space-y-3">
|
|
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
|
{joinBusinessMeta([`报销日期:${selectedTask.reimbursement.reimbursementDate ?? "-"}`, selectedTask.reimbursement.description])}
|
|
</div>
|
|
<Button asChild className="w-full" variant="secondary">
|
|
<Link
|
|
data-testid="mobile-approval-reimbursement-detail-link"
|
|
state={{ from: currentMobileSource }}
|
|
to={`/mobile/reimbursements/${encodeURIComponent(selectedTask.reimbursement.id)}`}
|
|
>
|
|
查看完整报销单
|
|
</Link>
|
|
</Button>
|
|
{!canUseRemote ? (
|
|
<p className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm text-orange-800">
|
|
当前只显示允许离线保存的摘要。请联网加载完整明细和票据后再处理该报销审批。
|
|
</p>
|
|
) : reimbursementDetailQuery.isLoading ? (
|
|
<p className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
|
正在加载完整报销内容;加载完成前所有审批动作均不可用。
|
|
</p>
|
|
) : reimbursementDetailQuery.isError ? (
|
|
<div className="space-y-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
|
|
<p>{resolveUserErrorMessage(reimbursementDetailQuery.error, "完整报销内容加载失败,暂时不能审批。")}</p>
|
|
<Button className="min-h-11" onClick={() => reimbursementDetailQuery.refetch()} type="button" variant="secondary">重试加载</Button>
|
|
</div>
|
|
) : null}
|
|
{selectedTask.workflowInstance?.contextData.parseStatus === "invalid" ? (
|
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
|
|
提交时检查记录无法解析,暂时不能审批;请联系系统管理员核对。
|
|
</p>
|
|
) : null}
|
|
{reimbursementEvidence ? (
|
|
<>
|
|
{selectedTask.task.status !== "pending" ? (
|
|
<p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">
|
|
当前版本可能包含申请人在退回后所做的修改;历史审批意见和提交时检查仍对应当时版本。
|
|
</p>
|
|
) : null}
|
|
<ReimbursementItemEvidenceList
|
|
evidence={reimbursementEvidence}
|
|
capabilities={{
|
|
onOpenInvoice: (invoice) => openInvoiceMutation.mutate(invoice.id),
|
|
onOpenAttachment: (attachment) => void handleOpenAttachment(attachment.id),
|
|
onOpenMaterialBundle: () => void handleOpenAllMaterials(),
|
|
onReferenceItem: (item) => appendReturnReference(buildReimbursementReturnReference({ item })),
|
|
onReferenceInvoice: (invoice) => appendReturnReference(buildReimbursementReturnReference({ invoice })),
|
|
}}
|
|
/>
|
|
<div className="grid gap-2" data-testid="mobile-reimbursement-approval-checks">
|
|
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
|
|
<p className="font-medium text-zinc-900">提交时检查</p>
|
|
{selectedTask.workflowInstance?.contextData.budgetCheckStatus === "missing" ? (
|
|
<p className="mt-1 text-amber-800">该次提交未记录预算快照。</p>
|
|
) : selectedTask.workflowInstance?.contextData.budgetCheckStatus === "not_applicable" ? (
|
|
<p className="mt-1 text-zinc-600">提交时未匹配适用预算。</p>
|
|
) : selectedTask.workflowInstance?.contextData.budgetCheck ? (
|
|
<p className="mt-1">预算 {selectedTask.workflowInstance.contextData.budgetCheck.budgetCode},提交后使用率 {selectedTask.workflowInstance.contextData.budgetCheck.usagePercent}%</p>
|
|
) : null}
|
|
<p className="mt-1">费用标准提示 {selectedTask.workflowInstance?.contextData.standardViolations.length ?? 0} 条</p>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
|
|
<p className="font-medium text-zinc-900">当前票据检查</p>
|
|
<p className="mt-1">票据 {reimbursementEvidence.currentCheck.invoiceCount} 张,尚未对应明细 {reimbursementEvidence.currentCheck.unlinkedInvoiceCount} 张;需人工核对 {reimbursementEvidence.currentCheck.manualReviewItemCount} 条,申报额超出票面 {reimbursementEvidence.currentCheck.amountExceededItemCount} 条。</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{selectedTask.leaveRequest ? (
|
|
<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.attendanceCorrection ? (
|
|
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
|
{joinBusinessMeta([
|
|
`类型:${entityLabel("attendance_correction")}`,
|
|
`日期:${selectedTask.attendanceCorrection.workDate ?? "-"}`,
|
|
selectedTask.attendanceCorrection.reason ?? null,
|
|
])}
|
|
</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>
|
|
) : (
|
|
<>
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs leading-5 text-zinc-600" data-testid="mobile-workflow-action-consequence-help">
|
|
<div><span className="font-medium text-zinc-800">退回:</span>原单回到申请人修改,保留单号和历史,修改后可重新提交。</div>
|
|
<div><span className="font-medium text-zinc-800">驳回:</span>本次流程终止或进入驳回态,申请人通常需要重新发起。</div>
|
|
</div>
|
|
|
|
<Label className="sr-only" htmlFor="mobile-approvals-comment">审批意见</Label>
|
|
<Textarea
|
|
id="mobile-approvals-comment"
|
|
data-testid="mobile-approvals-comment"
|
|
disabled={isSelectedReimbursement && !reimbursementDecisionReady}
|
|
onChange={(event) => setComment(event.target.value)}
|
|
placeholder="意见(驳回/退回必填)"
|
|
rows={3}
|
|
value={comment}
|
|
/>
|
|
|
|
{!canUseRemote ? (
|
|
<p className="text-xs text-zinc-500">
|
|
{isSelectedReimbursement
|
|
? "报销审批离线时不能保存审批草稿,请联网核对完整内容。"
|
|
: "离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。"}
|
|
</p>
|
|
) : null}
|
|
|
|
{showSecondaryActions ? (
|
|
<div className="mobile-list-card space-y-3" data-testid="mobile-approvals-secondary-actions">
|
|
<p className="text-sm font-medium text-zinc-900">其他处理</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
className="min-h-11 w-full"
|
|
data-testid="mobile-approvals-return"
|
|
disabled={isActionSubmitting || !reimbursementDecisionReady || !canUseRemote || !selectedTask.allowedActions.includes("return")}
|
|
onClick={() => requestMobileWorkflowAction("return")}
|
|
type="button"
|
|
variant="secondary"
|
|
>
|
|
退回
|
|
</Button>
|
|
<Button
|
|
className="min-h-11 w-full"
|
|
data-testid="mobile-approvals-reject"
|
|
disabled={isActionSubmitting || !reimbursementDecisionReady || !selectedTask.allowedActions.includes("reject")}
|
|
onClick={() => requestMobileWorkflowAction("reject")}
|
|
type="button"
|
|
variant="secondary"
|
|
>
|
|
驳回
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<MobileStickyActionBar
|
|
contentClassName="grid grid-cols-[minmax(0,1fr)_auto] gap-2"
|
|
testId="mobile-approvals-sticky-actions"
|
|
>
|
|
<Button
|
|
className="min-h-12 w-full"
|
|
data-testid="mobile-approvals-approve"
|
|
disabled={isActionSubmitting || !reimbursementDecisionReady || !selectedTask.allowedActions.includes("approve")}
|
|
onClick={() => requestMobileWorkflowAction("approve")}
|
|
type="button"
|
|
>
|
|
通过
|
|
</Button>
|
|
<Button
|
|
aria-expanded={showSecondaryActions}
|
|
className="min-h-12 px-4"
|
|
data-testid="mobile-approvals-more"
|
|
onClick={() => setShowSecondaryActions((current) => !current)}
|
|
type="button"
|
|
variant="secondary"
|
|
>
|
|
更多处理
|
|
</Button>
|
|
</MobileStickyActionBar>
|
|
|
|
{canTransfer && showSecondaryActions ? (
|
|
<div className="mobile-list-card space-y-3 text-sm">
|
|
<p className="font-medium text-zinc-800">转审</p>
|
|
<Label htmlFor="mobile-approvals-transfer-to">目标审批人</Label>
|
|
<select
|
|
id="mobile-approvals-transfer-to"
|
|
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>
|
|
|
|
<Label htmlFor="mobile-approvals-transfer-reason">转审理由</Label>
|
|
<Textarea
|
|
id="mobile-approvals-transfer-reason"
|
|
aria-describedby="mobile-approvals-transfer-help"
|
|
data-testid="mobile-approvals-transfer-reason"
|
|
onChange={(event) => setTransferReason(event.target.value)}
|
|
placeholder="转审理由(至少10字)"
|
|
rows={2}
|
|
value={transferReason}
|
|
/>
|
|
<p
|
|
id="mobile-approvals-transfer-help"
|
|
className={transferReason.trim().length >= 10 && transferToApproverId ? "text-xs text-emerald-700" : "text-xs text-amber-700"}
|
|
data-testid="mobile-approvals-transfer-disabled-reason"
|
|
>
|
|
{transferDisabledReason}
|
|
</p>
|
|
|
|
<Button
|
|
aria-describedby="mobile-approvals-transfer-help"
|
|
className="min-h-12 w-full"
|
|
data-testid="mobile-approvals-transfer"
|
|
disabled={
|
|
isActionSubmitting ||
|
|
!selectedTask.allowedActions.includes("transfer") ||
|
|
!transferToApproverId ||
|
|
transferReason.trim().length < 10
|
|
}
|
|
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>
|
|
<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) {
|
|
return;
|
|
}
|
|
if (canUseRemote) {
|
|
await actionMutation.mutateAsync(confirmAction);
|
|
return;
|
|
}
|
|
if (confirmAction !== "return") {
|
|
await queueOfflineDraft(confirmAction);
|
|
}
|
|
}}
|
|
/>
|
|
{downloadReasonDialog}
|
|
</>
|
|
);
|
|
}
|