405 行
16 KiB
TypeScript
405 行
16 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,
|
|
fetchWorkflowTaskSummary,
|
|
type WorkflowTaskSummary,
|
|
} 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;
|
|
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: "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 === "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 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 ?? [];
|
|
|
|
return (
|
|
<div className="space-y-4 md:space-y-6">
|
|
<PageHeader title="审批中心" subtitle="集中处理待办、查看已办和业务审批历史。" />
|
|
|
|
<div className="grid gap-3 md:grid-cols-4" data-testid="workflow-task-summary">
|
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
|
<div className="text-xs text-zinc-500">待办总数</div>
|
|
<div className="mt-2 text-2xl font-semibold text-zinc-900">{summaryQuery.data?.pendingTotal ?? "-"}</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
|
<div className="text-xs text-zinc-500">超时待办</div>
|
|
<div className="mt-2 text-2xl font-semibold text-amber-700">{summaryQuery.data?.overdueTotal ?? "-"}</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
|
<div className="text-xs text-zinc-500">最早待处理</div>
|
|
<div className="mt-2 text-sm font-medium text-zinc-900">{formatSummaryTime(summaryQuery.data?.earliestPendingAt)}</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
|
<div className="text-xs text-zinc-500">业务分布</div>
|
|
<div className="mt-2 truncate text-sm font-medium text-zinc-900">
|
|
{(summaryQuery.data?.byEntityType ?? [])
|
|
.filter((item) => item.pendingTotal > 0)
|
|
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
|
|
.join(" / ") || "-"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-std space-y-4 p-4 md:p-5">
|
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
|
<Tabs
|
|
data-testid="workflow-tasks-status-tabs"
|
|
value={status}
|
|
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
|
|
>
|
|
<TabsList className="flex w-full flex-wrap justify-start gap-1 xl:w-auto">
|
|
{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="flex items-center gap-2">
|
|
<label className="text-sm font-medium text-zinc-700" htmlFor="workflow-entity-type-filter">
|
|
业务类型
|
|
</label>
|
|
<select
|
|
id="workflow-entity-type-filter"
|
|
className="select-std min-w-36"
|
|
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>
|
|
|
|
{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="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">{displayTitle(task)}</div>
|
|
<div className="mt-1 text-xs text-zinc-500">
|
|
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
|
|
</div>
|
|
</div>
|
|
<StatusBadge className="shrink-0" status={task.status} />
|
|
</div>
|
|
<div className="mt-3 grid gap-1 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
|
<div>申请人:{displayApplicant(task)}</div>
|
|
<div>金额/天数:{displayMetric(task)}</div>
|
|
<div>当前节点:{task.stepName}</div>
|
|
<div>业务状态:{businessStatusLabel(task.businessSummary?.businessStatus)}</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={() => 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>当前节点</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={() => 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={8}>
|
|
暂无数据
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
<Pagination
|
|
page={page}
|
|
totalPages={totalPages}
|
|
onPageChange={(p) => updateSearchParams({ page: p })}
|
|
testIdPrefix="workflow-tasks"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|