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

248 行
10 KiB
TypeScript

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 } from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
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";
const PAGE_SIZE = 20;
function entityLabel(entityType: string | undefined) {
if (entityType === "leave") {
return "请假";
}
if (entityType === "reimbursement") {
return "报销";
}
if (entityType === "purchase_request") {
return "采购";
}
if (entityType === "travel_request") {
return "差旅";
}
return entityType || "-";
}
export function WorkflowTasksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const status =
(searchParams.get("status") as
| "pending"
| "completed"
| "rejected"
| "cancelled"
| "skipped"
| null) ?? "pending";
const page = Number(searchParams.get("page") ?? "1") || 1;
const query = useQuery({
queryKey: ["workflow", "tasks", status, page],
queryFn: () => fetchWorkflowTasks({ status, page, pageSize: PAGE_SIZE }),
});
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) => {
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 ?? [];
return (
<div className="space-y-4 md:space-y-6">
<PageHeader title="待我处理" subtitle="待处理审批。" />
<div className="card-std space-y-4 p-4 md:p-5">
<Tabs
data-testid="workflow-tasks-status-tabs"
value={status}
onValueChange={(value) => setSearchParams({ status: value, page: "1" })}
>
<TabsList className="flex w-full flex-wrap justify-start gap-1">
{[
{ value: "pending", label: "待处理" },
{ value: "completed", label: "已通过" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
{ value: "skipped", label: "已跳过" },
].map((item) => (
<TabsTrigger
data-testid={`workflow-tasks-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{query.isLoading ? <TableSkeleton columns={5} 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="space-y-3 md:hidden" data-testid="workflow-tasks-mobile-list">
{taskItems.length ? (
taskItems.map((task) => (
<article className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm" key={task.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{task.stepName}</div>
<div className="mt-1 text-xs text-zinc-500">
{entityLabel(task.workflowInstance?.entityType)} · {new Date(task.createdAt).toLocaleString()}
</div>
</div>
<StatusBadge className="shrink-0" status={task.status} />
</div>
<div className="mt-3 truncate rounded-lg bg-zinc-50 px-3 py-2 font-mono text-xs text-zinc-500">
{task.workflowInstance?.entityId ?? task.id}
</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={() => approveMutation.mutate(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>
))
) : (
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
暂无数据
</div>
)}
</div>
<div className="hidden rounded-lg border border-zinc-200 md:block">
<Table data-testid="workflow-tasks-table">
<TableHeader>
<tr>
<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">{task.stepName}</div>
<div className="text-xs text-zinc-500">{task.id}</div>
</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">
{entityLabel(task.workflowInstance?.entityType)}
</div>
<div className="font-mono text-xs text-zinc-500">
{task.workflowInstance?.entityId ?? "-"}
</div>
</TableCell>
<TableCell><StatusBadge status={task.status} /></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={() => approveMutation.mutate(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="py-3 text-zinc-500" colSpan={5}>
暂无数据
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
) : null}
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(p) => setSearchParams({ status, page: String(p) })}
testIdPrefix="workflow-tasks"
/>
</div>
</div>
);
}