文件
hyb-oa/client/src/pages/WorkflowTasksPage.tsx
T

454 行
18 KiB
TypeScript

import { useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
approveWorkflowTask,
fetchWorkflowTasks,
fetchWorkflowTaskSummary,
type WorkflowTaskSummary,
} from "../api/workflow";
import { Pagination } from "../components/common/Pagination";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import {
FilterBar,
ListWorkspace,
PrimaryTable,
StatStrip,
WorkspaceEmptyState,
WorkspaceHeader,
} from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { TEST_IDS } from "../lib/testIds";
import { getWorkflowConfirmCopy } from "../lib/workflowActionConfirm";
const PAGE_SIZE = 20;
const WORKFLOW_STATUS_OPTIONS = [
{ value: "pending", label: "待处理" },
{ value: "completed", label: "已处理" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
{ value: "skipped", label: "已跳过" },
] as const;
const WORKFLOW_ENTITY_OPTIONS = [
{ value: "all", label: "全部业务" },
{ value: "reimbursement", label: "报销" },
{ value: "leave", label: "请假" },
{ value: "attendance_correction", label: "考勤" },
{ value: "purchase_request", label: "采购" },
{ value: "travel_request", label: "差旅" },
] as const;
type WorkflowStatusFilter = (typeof WORKFLOW_STATUS_OPTIONS)[number]["value"];
type WorkflowEntityFilter = (typeof WORKFLOW_ENTITY_OPTIONS)[number]["value"];
function normalizeStatusFilter(value: string | null): WorkflowStatusFilter {
return WORKFLOW_STATUS_OPTIONS.some((item) => item.value === value)
? (value as WorkflowStatusFilter)
: "pending";
}
function normalizeEntityFilter(value: string | null): WorkflowEntityFilter {
return WORKFLOW_ENTITY_OPTIONS.some((item) => item.value === value)
? (value as WorkflowEntityFilter)
: "all";
}
function entityLabel(entityType: string | undefined) {
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 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 isInternalId(value: string | null | undefined) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? "");
}
function publicReference(value: string | null | undefined) {
if (!value || isInternalId(value)) {
return null;
}
return value;
}
function displayReference(task: WorkflowTaskSummary) {
return publicReference(task.businessSummary?.reference ?? task.workflowInstance?.entityId) ?? "未生成业务编号";
}
function displayTitle(task: WorkflowTaskSummary) {
return task.businessSummary?.title ?? task.stepName;
}
function displayApplicant(task: WorkflowTaskSummary) {
return task.businessSummary?.applicantName ?? "-";
}
function displayMetric(task: WorkflowTaskSummary) {
return task.businessSummary?.amount ?? task.businessSummary?.duration ?? "-";
}
function formatSummaryTime(value: string | null | undefined) {
if (!value) {
return "-";
}
return value.slice(0, 19).replace("T", " ");
}
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
export function WorkflowTasksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [confirmApproveTaskId, setConfirmApproveTaskId] = useState<string | null>(null);
const queryClient = useQueryClient();
const status = normalizeStatusFilter(searchParams.get("status"));
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
const page = Number(searchParams.get("page") ?? "1") || 1;
function updateSearchParams(next: {
status?: WorkflowStatusFilter;
entityType?: WorkflowEntityFilter;
page?: number;
}) {
const nextStatus = next.status ?? status;
const nextEntityType = next.entityType ?? entityType;
const params: Record<string, string> = {
status: nextStatus,
page: String(next.page ?? page),
};
if (nextEntityType !== "all") {
params.entityType = nextEntityType;
}
setSearchParams(params);
}
const query = useQuery({
queryKey: ["workflow", "tasks", status, entityType, page],
queryFn: () =>
fetchWorkflowTasks({
status,
entityType: entityType === "all" ? undefined : entityType,
page,
pageSize: PAGE_SIZE,
}),
});
const summaryQuery = useQuery({
queryKey: ["workflow", "tasks", "summary", "mine"],
queryFn: () => fetchWorkflowTaskSummary(),
});
const approveMutation = useMutation({
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
]);
toast.success("审批已通过");
},
onError: (error) => {
if (isAlreadyProcessedTaskError(error)) {
void queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] });
}
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "审批失败,请稍后重试";
toast.error(message);
},
});
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
const taskItems = query.data?.items ?? [];
const confirmCopy = confirmApproveTaskId ? getWorkflowConfirmCopy("approve") : null;
return (
<>
<ListWorkspace>
<WorkspaceHeader
eyebrow="审批"
title="审批中心"
subtitle="任务、节点、业务状态。"
/>
<div data-testid="workflow-task-summary">
<StatStrip columns={4} className="ops-status-strip">
<MetricCard title="待办总数" value={summaryQuery.data?.pendingTotal ?? "-"} />
<MetricCard title="超时待办" value={summaryQuery.data?.overdueTotal ?? "-"} tone="warning" />
<MetricCard title="最早待处理" value={formatSummaryTime(summaryQuery.data?.earliestPendingAt)} />
<MetricCard
title="业务分布"
value={
<span className="block truncate text-base">
{(summaryQuery.data?.byEntityType ?? [])
.filter((item) => item.pendingTotal > 0)
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
.join(" / ") || "-"}
</span>
}
/>
</StatStrip>
</div>
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<Tabs
data-testid="workflow-tasks-status-tabs"
value={status}
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{WORKFLOW_STATUS_OPTIONS.map((item) => (
<TabsTrigger
data-testid={`workflow-tasks-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="ops-command-bar__actions">
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
业务类型
</label>
<select
id="workflow-entity-type-filter"
className="select-sm min-w-32"
data-testid="workflow-tasks-entity-filter"
value={entityType}
onChange={(event) => updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })}
>
{WORKFLOW_ENTITY_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
</div>
</FilterBar>
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
{query.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(query.error)
? `${query.error.code}: ${mapApiErrorMessage(
query.error.code,
query.error.details,
query.error.message,
)}`
: "加载待办失败,请稍后重试"}
</p>
) : null}
{!query.isLoading && !query.isError ? (
<>
<div className="mobile-page-stack md:hidden" data-testid="workflow-tasks-mobile-list">
{taskItems.length ? (
taskItems.map((task) => (
<article className="mobile-list-card" key={task.id}>
<div className="mobile-list-card__header">
<div className="min-w-0">
<div className="mobile-list-card__title">{displayTitle(task)}</div>
<div className="mobile-list-card__subtitle">
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
</div>
</div>
<StatusBadge className="mobile-status-slot" status={task.status} />
</div>
<div className="mobile-field-list mt-3 rounded-lg bg-zinc-50 px-3 py-2">
<div className="mobile-field-row">
<span className="mobile-field-label">申请人</span>
<span className="mobile-field-value">{displayApplicant(task)}</span>
</div>
<div className="mobile-field-row">
<span className="mobile-field-label">金额/天数</span>
<span className="mobile-field-value">{displayMetric(task)}</span>
</div>
<div className="mobile-field-row">
<span className="mobile-field-label">当前节点</span>
<span className="mobile-field-value">{task.stepName}</span>
</div>
<div className="mobile-field-row">
<span className="mobile-field-label">业务状态</span>
<span className="mobile-field-value">{businessStatusLabel(task.businessSummary?.businessStatus)}</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
<Button
className="min-h-11"
size="sm"
type="button"
data-testid={TEST_IDS.workflow.quickApprove}
disabled={approveMutation.isPending && approveMutation.variables === task.id}
onClick={() => setConfirmApproveTaskId(task.id)}
>
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
</Button>
) : null}
<Button
asChild
className={`min-h-11 ${
status === "pending" && (task.allowedActions ?? []).includes("approve") ? "" : "col-span-2"
}`}
size="sm"
variant="secondary"
data-testid={TEST_IDS.workflow.detailLink}
>
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
</Button>
</div>
</article>
))
) : (
<WorkspaceEmptyState />
)}
</div>
<PrimaryTable className="hidden md:block ops-table-focus">
<Table data-testid="workflow-tasks-table">
<TableHeader>
<tr>
<TableHead>业务类型 / 编号</TableHead>
<TableHead>标题</TableHead>
<TableHead>申请人</TableHead>
<TableHead>金额/天数</TableHead>
<TableHead>当前节点</TableHead>
<TableHead>业务状态</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-24">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{taskItems.length ? (
taskItems.map((task) => (
<TableRow data-testid="workflow-task-row" key={task.id}>
<TableCell>
<div className="font-medium text-zinc-900">{entityLabel(task.workflowInstance?.entityType)}</div>
<div className="text-xs text-zinc-500">{displayReference(task)}</div>
</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">{displayTitle(task)}</div>
<div className="mt-1"><StatusBadge status={task.status} /></div>
</TableCell>
<TableCell>{displayApplicant(task)}</TableCell>
<TableCell>{displayMetric(task)}</TableCell>
<TableCell>{task.stepName}</TableCell>
<TableCell>{businessStatusLabel(task.businessSummary?.businessStatus)}</TableCell>
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
<Button
size="sm"
type="button"
data-testid={TEST_IDS.workflow.quickApprove}
disabled={approveMutation.isPending && approveMutation.variables === task.id}
onClick={() => setConfirmApproveTaskId(task.id)}
>
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
</Button>
) : null}
<Button
asChild
size="sm"
variant="secondary"
data-testid={TEST_IDS.workflow.detailLink}
>
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={8}>
暂无数据
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</PrimaryTable>
</>
) : null}
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(p) => updateSearchParams({ page: p })}
testIdPrefix="workflow-tasks"
/>
</ListWorkspace>
<ConfirmDialog
open={Boolean(confirmCopy)}
onOpenChange={(open) => {
if (!open) {
setConfirmApproveTaskId(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 (!confirmApproveTaskId) {
return;
}
await approveMutation.mutateAsync(confirmApproveTaskId);
}}
/>
</>
);
}