483 行
19 KiB
TypeScript
483 行
19 KiB
TypeScript
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>
|
||
);
|
||
}
|