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

483 行
19 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
fetchWorkflowTasks,
fetchWorkflowTaskSummary,
type WorkflowTaskSummary,
} from "../api/workflow";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import {
FilterBar,
ListWorkspace,
PrimaryTable,
WorkspaceEmptyState,
WorkspaceHeader,
} from "../components/patterns/ListWorkspace";
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 { formatDisplayDateTime } from "../lib/displayFormat";
import { TEST_IDS } from "../lib/testIds";
const PAGE_SIZE = 20;
const WORKFLOW_DEFAULT_DUE_MS = 24 * 60 * 60 * 1000;
const WORKFLOW_STATUS_OPTIONS = [
{ value: "pending", label: "待处理" },
{ value: "completed", label: "已处理" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
{ value: "skipped", label: "已跳过" },
] as const;
const WORKFLOW_HISTORY_STATUS_OPTIONS = WORKFLOW_STATUS_OPTIONS.filter((item) => item.value !== "pending");
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 displayTaskTitle(task: WorkflowTaskSummary) {
const typeLabel = entityLabel(task.workflowInstance?.entityType);
const title = displayTitle(task);
if (!title || title === "-") {
return typeLabel;
}
if (title === typeLabel || title.startsWith(`${typeLabel}:`) || title.startsWith(`${typeLabel}:`)) {
return title;
}
return `${typeLabel}:${title}`;
}
function displayApplicant(task: WorkflowTaskSummary) {
return task.businessSummary?.applicantName ?? "-";
}
function displayMetric(task: WorkflowTaskSummary) {
return task.businessSummary?.amount ?? task.businessSummary?.duration ?? "-";
}
function parseTimestamp(value: string | null | undefined) {
if (!value) {
return null;
}
const timestamp = new Date(value).getTime();
return Number.isNaN(timestamp) ? null : timestamp;
}
function formatWaitingDuration(value: string | null | undefined) {
const timestamp = parseTimestamp(value);
if (!timestamp) {
return "等待时间未知";
}
const diff = Math.max(0, Date.now() - timestamp);
const minutes = Math.floor(diff / (60 * 1000));
if (minutes < 1) {
return "刚刚接收";
}
if (minutes < 60) {
return `已等待 ${minutes} 分钟`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `已等待 ${hours} 小时`;
}
return `已等待 ${Math.floor(hours / 24)} 天`;
}
function formatReceivedTime(value: string | null | undefined) {
const text = formatDisplayDateTime(value);
return text === "-" ? "接收时间未知" : `接收于 ${text}`;
}
function formatTaskTimeline(task: WorkflowTaskSummary) {
if (task.status !== "pending" && task.completedAt) {
const text = formatDisplayDateTime(task.completedAt);
return text === "-" ? "完成时间未知" : `完成于 ${text}`;
}
return formatWaitingDuration(task.createdAt);
}
function isWithinDefaultDueWindow(value: string | null | undefined) {
const timestamp = parseTimestamp(value);
if (!timestamp) {
return false;
}
return Date.now() - timestamp < WORKFLOW_DEFAULT_DUE_MS;
}
function isTaskDefaultOverdue(task: WorkflowTaskSummary) {
return task.status === "pending" && !isWithinDefaultDueWindow(task.createdAt);
}
function workflowTaskStatusLabel(status: string) {
const match = WORKFLOW_STATUS_OPTIONS.find((item) => item.value === status);
return match?.label ?? status;
}
function taskReason(task: WorkflowTaskSummary) {
return task.stepName ? `${task.stepName}找你处理` : "当前节点找你处理";
}
function taskLeadMeta(task: WorkflowTaskSummary) {
const metric = displayMetric(task);
return [displayApplicant(task), metric !== "-" ? metric : null, taskReason(task)].filter(Boolean).join(" · ");
}
function taskSecondaryMeta(task: WorkflowTaskSummary) {
return [formatReceivedTime(task.createdAt), displayReference(task)].join(" · ");
}
export function WorkflowTasksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const status = normalizeStatusFilter(searchParams.get("status"));
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
const page = Number(searchParams.get("page") ?? "1") || 1;
const taskTab = status === "pending" ? "pending" : "processed";
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 totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
const taskItems = query.data?.items ?? [];
const isDefaultTaskView = status === "pending" && entityType === "all";
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
const pendingEntitySummary =
(summaryQuery.data?.byEntityType ?? [])
.filter((item) => item.pendingTotal > 0)
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
.join(" / ") || "-";
const earliestPendingWaiting = summaryQuery.data?.earliestPendingAt
? formatWaitingDuration(summaryQuery.data.earliestPendingAt)
: "暂无待处理";
return (
<ListWorkspace>
<WorkspaceHeader title="待我处理" />
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<div className="flex min-w-0 flex-col gap-3 md:flex-row md:items-center">
<Tabs
data-testid="workflow-tasks-status-tabs"
value={taskTab}
onValueChange={(value) =>
updateSearchParams({
status: value === "pending" ? "pending" : "completed",
page: 1,
})
}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
<TabsTrigger data-testid="workflow-tasks-tab-pending" value="pending">
待处理
</TabsTrigger>
<TabsTrigger data-testid="workflow-tasks-tab-processed" value="processed">
已处理
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex min-w-0 flex-wrap items-center gap-3" data-testid="workflow-tasks-focus">
<span className="text-sm font-medium text-zinc-900">待处理 {pendingTotal}</span>
<span className="text-sm text-zinc-500">{earliestPendingWaiting}</span>
{summaryQuery.data?.overdueTotal ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">
超时 {summaryQuery.data.overdueTotal}
</span>
) : null}
</div>
</div>
<div className="ops-command-bar__actions">
<span className="hidden text-xs text-zinc-500 xl:inline">业务:{pendingEntitySummary}</span>
{taskTab === "processed" ? (
<>
<label className="ops-filter-label" htmlFor="workflow-status-filter">
记录
</label>
<select
id="workflow-status-filter"
className="select-sm min-w-28"
data-testid="workflow-tasks-processed-status-filter"
value={status === "pending" ? "completed" : status}
onChange={(event) => updateSearchParams({ status: event.target.value as WorkflowStatusFilter, page: 1 })}
>
{WORKFLOW_HISTORY_STATUS_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</>
) : null}
<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={4} 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">{displayTaskTitle(task)}</div>
<div className="mobile-list-card__subtitle">
{taskLeadMeta(task)}
</div>
</div>
<StatusBadge className="mobile-status-slot" status={task.status} label={workflowTaskStatusLabel(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">{formatTaskTimeline(task)}</span>
</div>
<div className="mobile-field-row">
<span className="mobile-field-label">编号</span>
<span className="mobile-field-value">{displayReference(task)}</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
asChild
className="col-span-2 min-h-11"
size="sm"
data-testid={TEST_IDS.workflow.detailLink}
>
<Link to={`/workflow/tasks/${task.id}`}>
{task.status === "pending" ? "处理" : "查看详情"}
</Link>
</Button>
</div>
</article>
))
) : (
<WorkspaceEmptyState>
{isDefaultTaskView ? "当前没有待处理任务。" : "当前筛选下没有审批任务。"}
</WorkspaceEmptyState>
)}
</div>
<PrimaryTable className="hidden md:block ops-table-focus">
<Table data-testid="workflow-tasks-table">
<TableHeader>
<tr>
<TableHead className="min-w-[360px]">待处理事项</TableHead>
<TableHead className="min-w-[180px]">状态 / 时间</TableHead>
<TableHead className="min-w-[160px]">业务编号</TableHead>
<TableHead className="w-36 text-right">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{taskItems.length ? (
taskItems.map((task) => (
<TableRow data-testid="workflow-task-row" key={task.id}>
<TableCell>
<div className="max-w-[560px] min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="truncate text-sm font-semibold text-zinc-900">{displayTaskTitle(task)}</div>
<StatusBadge status={task.status} label={workflowTaskStatusLabel(task.status)} />
</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">{taskLeadMeta(task)}</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">{taskSecondaryMeta(task)}</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium text-zinc-900">{formatTaskTimeline(task)}</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{isTaskDefaultOverdue(task) ? (
<span className="badge-warning">可能超时</span>
) : null}
{businessStatusLabel(task.businessSummary?.businessStatus) !== "-" &&
businessStatusLabel(task.businessSummary?.businessStatus) !== workflowTaskStatusLabel(task.status) ? (
<span className="badge-neutral">{businessStatusLabel(task.businessSummary?.businessStatus)}</span>
) : null}
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium text-zinc-900">{displayReference(task)}</div>
<div className="mt-1 text-xs text-zinc-500">{entityLabel(task.workflowInstance?.entityType)}</div>
</TableCell>
<TableCell>
<div className="flex flex-col items-stretch gap-2 sm:items-end">
<Button
asChild
size="sm"
data-testid={TEST_IDS.workflow.detailLink}
>
<Link to={`/workflow/tasks/${task.id}`}>
{task.status === "pending" ? "处理" : "查看详情"}
</Link>
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={4}>
{isDefaultTaskView ? "当前没有待处理任务" : "当前筛选下没有审批任务"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</PrimaryTable>
</>
) : null}
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(p) => updateSearchParams({ page: p })}
testIdPrefix="workflow-tasks"
/>
</ListWorkspace>
);
}