From 40c3b7632a6c232e428c6fee499da3121f933006 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 8 Apr 2026 09:53:47 +0800 Subject: [PATCH] =?UTF-8?q?R55=20A1-A6=EF=BC=9A=E6=8A=A5=E9=94=80=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E4=BB=AA=E8=A1=A8=E7=9B=98=E4=B8=8E=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=AE=A1=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/reimbursements.ts | 82 ++ client/src/api/workflow.ts | 32 + .../src/pages/ReimbursementDashboardPage.tsx | 343 +++++ client/src/pages/ReimbursementsPage.tsx | 501 ++++++- client/src/router/index.tsx | 10 + .../router/lazy/ReimbursementDashboardPage.ts | 1 + client/src/router/lazy/index.ts | 1 + ...6-04-08_r55-reimbursement-enhancements.csv | 7 + server/src/routes/reimbursements.js | 491 ++++++- server/src/routes/workflow.js | 1167 ++++++++++------- .../unit/routes/reimbursements.stats.test.js | 226 ++++ .../tests/unit/routes/workflow.batch.test.js | 351 +++++ 12 files changed, 2668 insertions(+), 544 deletions(-) create mode 100644 client/src/pages/ReimbursementDashboardPage.tsx create mode 100644 client/src/router/lazy/ReimbursementDashboardPage.ts create mode 100644 issues/2026-04-08_r55-reimbursement-enhancements.csv create mode 100644 server/tests/unit/routes/reimbursements.stats.test.js create mode 100644 server/tests/unit/routes/workflow.batch.test.js diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index fcd64655..72cf4f2c 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -53,6 +53,17 @@ export type ReimbursementListItem = { projectNo: string; name: string; } | null; + pendingWorkflow: { + instanceId: string; + stepId: string; + allowedActions: Array<"approve" | "reject" | "return" | "transfer">; + task: { + id: string; + stepName: string; + assigneeId: string | null; + assigneeRole: string | null; + } | null; + } | null; createdAt: string | null; updatedAt: string | null; }; @@ -147,6 +158,49 @@ export type ReimbursementListQuery = { scope?: "own" | "all"; }; +export type ReimbursementStatsQuery = { + dateFrom?: string; + dateTo?: string; + departmentId?: string; +}; + +export type ReimbursementStatsGranularity = "month" | "week"; + +export type ReimbursementStatsSummary = { + totalCount: number; + totalAmount: string; + byStatus: Record< + ReimbursementStatus, + { + count: number; + totalAmount: string; + } + >; + avgProcessingDays: number | null; +}; + +export type ReimbursementStatsTrendItem = { + period: string | null; + count: number; + amount: string; + paidCount: number; + paidAmount: string; +}; + +export type ReimbursementTopExpenseTypeItem = { + expenseTypeId: string; + expenseTypeName: string; + count: number; + totalAmount: string; +}; + +export type ReimbursementApprovalBottleneckItem = { + assigneeId: string | null; + assigneeName: string; + pendingCount: number; + oldestPendingDays: number; +}; + export type ReimbursementPayPayload = { paymentVoucherNo?: string; paymentDate?: string; @@ -214,6 +268,34 @@ export async function fetchReimbursements(query: ReimbursementListQuery = {}) { ); } +export async function fetchReimbursementStatsSummary(query: ReimbursementStatsQuery = {}) { + return apiRequestData( + `/api/reimbursements/stats/summary${toQueryString(query as Record)}`, + ); +} + +export async function fetchReimbursementStatsTrend( + query: ReimbursementStatsQuery & { granularity?: ReimbursementStatsGranularity } = {}, +) { + return apiRequestData<{ items: ReimbursementStatsTrendItem[] }>( + `/api/reimbursements/stats/trend${toQueryString(query as Record)}`, + ); +} + +export async function fetchReimbursementTopExpenseTypes( + query: ReimbursementStatsQuery & { limit?: number } = {}, +) { + return apiRequestData<{ items: ReimbursementTopExpenseTypeItem[] }>( + `/api/reimbursements/stats/top-expense-types${toQueryString(query as Record)}`, + ); +} + +export async function fetchReimbursementApprovalBottleneck(query: ReimbursementStatsQuery = {}) { + return apiRequestData<{ items: ReimbursementApprovalBottleneckItem[] }>( + `/api/reimbursements/stats/approval-bottleneck${toQueryString(query as Record)}`, + ); +} + export async function fetchReimbursementDetail(reimbursementId: string) { return apiRequestData(`/api/reimbursements/${reimbursementId}`); } diff --git a/client/src/api/workflow.ts b/client/src/api/workflow.ts index 3c197cb2..70a8ea8a 100644 --- a/client/src/api/workflow.ts +++ b/client/src/api/workflow.ts @@ -201,6 +201,24 @@ export type WorkflowApproverQuery = { pageSize?: number; }; +export type WorkflowBatchActionResult = { + taskId: string; + outcome: "approved" | "rejected" | "skipped" | "error"; + reason?: string; +}; + +export type WorkflowBatchActionSummary = { + approved: number; + rejected?: number; + skipped: number; + errored: number; +}; + +export type WorkflowBatchActionResponse = { + results: WorkflowBatchActionResult[]; + summary: WorkflowBatchActionSummary; +}; + function toQueryString(query: Record) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { @@ -255,3 +273,17 @@ export async function transferWorkflowTask(taskId: string, toApproverId: string, body: JSON.stringify({ toApproverId, reason }), }); } + +export async function batchApproveWorkflowTasks(payload: { taskIds: string[]; comment?: string }) { + return apiRequestData("/api/workflow/batch-approve", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function batchRejectWorkflowTasks(payload: { taskIds: string[]; comment: string }) { + return apiRequestData("/api/workflow/batch-reject", { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/pages/ReimbursementDashboardPage.tsx b/client/src/pages/ReimbursementDashboardPage.tsx new file mode 100644 index 00000000..248395b0 --- /dev/null +++ b/client/src/pages/ReimbursementDashboardPage.tsx @@ -0,0 +1,343 @@ +import { useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchDepartments } from "../api/departments"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { + fetchReimbursementApprovalBottleneck, + fetchReimbursementStatsSummary, + fetchReimbursementStatsTrend, + fetchReimbursementTopExpenseTypes, + type ReimbursementStatsQuery, +} from "../api/reimbursements"; +import { isApiError } from "../api/types"; +import { PageHeader } from "../components/common/PageHeader"; +import { SectionPanel } from "../components/patterns/SectionPanel"; +import { Button } from "../components/ui/button"; + +function currentDateString() { + return new Date().toISOString().slice(0, 10); +} + +function defaultDateFromString() { + const now = new Date(); + now.setDate(1); + now.setMonth(now.getMonth() - 2); + return now.toISOString().slice(0, 10); +} + +function resolveErrorMessage(error: unknown) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + if (error instanceof Error) { + return error.message; + } + return "加载失败,请稍后重试。"; +} + +function formatCurrency(value: string | number | null | undefined) { + const numeric = Number(value ?? 0); + return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format( + Number.isFinite(numeric) ? numeric : 0, + ); +} + +function formatAvgDays(value: number | null) { + if (value == null) { + return "-"; + } + const normalized = Number(value); + if (!Number.isFinite(normalized)) { + return "-"; + } + return `${normalized.toFixed(1)} 天`; +} + +function formatPeriod(value: string | null) { + if (!value) { + return "-"; + } + return value.length === 7 ? value : value.replace("T", " ").slice(0, 10); +} + +function normalizeBarWidth(current: number, max: number) { + if (max <= 0 || current <= 0) { + return "0%"; + } + return `${Math.max(8, Math.min(100, (current / max) * 100))}%`; +} + +export function ReimbursementDashboardPage() { + const queryClient = useQueryClient(); + const [dateFrom, setDateFrom] = useState(defaultDateFromString()); + const [dateTo, setDateTo] = useState(currentDateString()); + const [departmentId, setDepartmentId] = useState(""); + + const filters = useMemo( + () => ({ + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + departmentId: departmentId || undefined, + }), + [dateFrom, dateTo, departmentId], + ); + + const departmentsQuery = useQuery({ + queryKey: ["departments", "reimbursements-dashboard"], + queryFn: () => fetchDepartments({ includeArchived: false }), + }); + + const summaryQuery = useQuery({ + queryKey: ["reimbursements", "stats", "summary", filters], + queryFn: () => fetchReimbursementStatsSummary(filters), + }); + + const trendQuery = useQuery({ + queryKey: ["reimbursements", "stats", "trend", filters], + queryFn: () => fetchReimbursementStatsTrend({ ...filters, granularity: "month" }), + }); + + const topExpenseTypesQuery = useQuery({ + queryKey: ["reimbursements", "stats", "top-expense-types", filters], + queryFn: () => fetchReimbursementTopExpenseTypes({ ...filters, limit: 10 }), + }); + + const bottleneckQuery = useQuery({ + queryKey: ["reimbursements", "stats", "approval-bottleneck", filters], + queryFn: () => fetchReimbursementApprovalBottleneck(filters), + }); + + const loading = + summaryQuery.isLoading || + trendQuery.isLoading || + topExpenseTypesQuery.isLoading || + bottleneckQuery.isLoading; + + const firstError = + summaryQuery.error ?? + trendQuery.error ?? + topExpenseTypesQuery.error ?? + bottleneckQuery.error ?? + departmentsQuery.error; + + const summary = summaryQuery.data; + const trendItems = trendQuery.data?.items ?? []; + const topExpenseTypes = topExpenseTypesQuery.data?.items ?? []; + const bottlenecks = bottleneckQuery.data?.items ?? []; + + const maxTrendCount = Math.max(1, ...trendItems.map((item) => Number(item.count ?? 0))); + const maxTrendAmount = Math.max(1, ...trendItems.map((item) => Number(item.amount ?? 0))); + const maxExpenseTypeAmount = Math.max(1, ...topExpenseTypes.map((item) => Number(item.totalAmount ?? 0))); + + async function handleRefresh() { + await queryClient.invalidateQueries({ queryKey: ["reimbursements", "stats"] }); + } + + return ( +
+ void handleRefresh()}> + 刷新 + + } + /> + + {firstError ? ( +

+ {resolveErrorMessage(firstError)} +

+ ) : null} + +
+
+
+ + setDateFrom(event.target.value)} + /> +
+
+ + setDateTo(event.target.value)} + /> +
+
+ + +
+
+
+ 默认窗口:近 3 个自然月 +
+
+
+
+ +
+
+
报销总数
+
+ {loading ? "..." : summary?.totalCount ?? 0} +
+
+
+
报销总金额
+
+ {loading ? "..." : formatCurrency(summary?.totalAmount)} +
+
+
+
平均处理天数
+
+ {loading ? "..." : formatAvgDays(summary?.avgProcessingDays ?? null)} +
+
+
+
待审批数
+
+ {loading ? "..." : summary?.byStatus.reviewing.count ?? 0} +
+
+
+ + + {trendItems.length ? ( +
+ {trendItems.map((item) => ( +
+
+
{formatPeriod(item.period)}
+
已支付 {item.paidCount} 单
+
+
+
+
+ 数量 + {item.count} 单 +
+
+
+
+
+
+
+ 金额 + {formatCurrency(item.amount)} +
+
+
+
+
+
+
+ ))} +
+ ) : ( +
当前筛选下暂无趋势数据。
+ )} + + +
+ + {topExpenseTypes.length ? ( +
+ {topExpenseTypes.map((item, index) => ( +
+
+
+
+ {index + 1}. {item.expenseTypeName} +
+
{item.count} 条报销明细
+
+
{formatCurrency(item.totalAmount)}
+
+
+
+
+
+ ))} +
+ ) : ( +
当前筛选下暂无费用类型排行。
+ )} + + + + {bottlenecks.length ? ( +
+ {bottlenecks.map((item) => { + const isStale = item.oldestPendingDays > 3; + return ( +
+
+
+
{item.assigneeName}
+
+ 待处理 {item.pendingCount} 单 +
+
+
+ 最长等待 {item.oldestPendingDays} 天 +
+
+
+ ); + })} +
+ ) : ( +
当前筛选下暂无审批瓶颈。
+ )} +
+
+
+ ); +} diff --git a/client/src/pages/ReimbursementsPage.tsx b/client/src/pages/ReimbursementsPage.tsx index 51e1ad0a..572864b3 100644 --- a/client/src/pages/ReimbursementsPage.tsx +++ b/client/src/pages/ReimbursementsPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isApiError } from "../api/types"; @@ -6,6 +6,11 @@ import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchMyDelegations } from "../api/delegations"; import { exportReimbursements } from "../api/exports"; import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements"; +import { + batchApproveWorkflowTasks, + batchRejectWorkflowTasks, + type WorkflowBatchActionResponse, +} from "../api/workflow"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { toast } from "sonner"; @@ -17,31 +22,82 @@ import { TableSkeleton } from "../components/common/TableSkeleton"; import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace"; import { MetricCard } from "../components/patterns/MetricCard"; import { Button } from "../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; +import { Textarea } from "../components/ui/textarea"; const PAGE_SIZE = 20; +type BatchAction = "approve" | "reject"; + +type BatchResultState = { + action: BatchAction; + summary: WorkflowBatchActionResponse["summary"]; + results: Array< + WorkflowBatchActionResponse["results"][number] & { + reimbursementId: string | null; + formNumber: string | null; + } + >; +}; + function formatCurrency(value: number) { return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value); } +function resolveErrorMessage(error: unknown, fallback: string) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + return error instanceof Error ? error.message : fallback; +} + +function formatBatchOutcome(action: BatchAction, outcome: string) { + if (outcome === "approved") { + return "已通过"; + } + if (outcome === "rejected") { + return "已驳回"; + } + if (outcome === "skipped") { + return "已跳过"; + } + if (outcome === "error") { + return "失败"; + } + return action === "approve" ? "已通过" : "已驳回"; +} + export function ReimbursementsPage() { const [searchParams, setSearchParams] = useSearchParams(); const { permissions, me } = useAuth(); const queryClient = useQueryClient(); const [exportError, setExportError] = useState(""); + const [batchError, setBatchError] = useState(""); + const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState(""); + const [batchMode, setBatchMode] = useState(false); + const [selectedReimbursementIds, setSelectedReimbursementIds] = useState([]); + const [batchDialogAction, setBatchDialogAction] = useState(null); + const [batchComment, setBatchComment] = useState(""); + const [batchResult, setBatchResult] = useState(null); const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft"; const page = Number(searchParams.get("page") ?? "1") || 1; const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own"; const canViewAll = hasPermission(permissions, "expense.view_all"); + const canApproveExpense = hasPermission(permissions, "expense.approve"); const canProxySubmit = hasPermission(permissions, "expense.proxy_submit"); const isAdministration = (me?.roleCodes ?? []).includes("administration"); const showProxyOwnerSelector = canProxySubmit && isAdministration; - const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState(""); - const listQuery = useQuery({ queryKey: ["reimbursements", "list", status, page, scope], queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }), @@ -67,6 +123,33 @@ export function ReimbursementsPage() { ); }, [items]); + const actionableItems = useMemo( + () => + items.filter( + (item) => + Boolean(item.pendingWorkflow?.task?.id) && + Boolean(item.pendingWorkflow?.allowedActions.includes("approve")), + ), + [items], + ); + const actionableIdSet = useMemo( + () => new Set(actionableItems.map((item) => item.id)), + [actionableItems], + ); + const canShowBatchEntry = + status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0; + const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id)); + const allActionableSelected = + actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id)); + + useEffect(() => { + setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id))); + if (!canShowBatchEntry) { + setBatchMode(false); + setBatchDialogAction(null); + } + }, [actionableIdSet, canShowBatchEntry]); + const createMutation = useMutation({ mutationFn: () => createReimbursement({ @@ -76,12 +159,8 @@ export function ReimbursementsPage() { await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); toast.success("报销单已创建"); }, - onError: (err) => { - toast.error( - isApiError(err) - ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` - : "创建报销单失败", - ); + onError: (error) => { + toast.error(resolveErrorMessage(error, "创建报销单失败")); }, }); @@ -92,20 +171,128 @@ export function ReimbursementsPage() { setExportError(""); toast.success("导出完成"); }, - onError: (err) => { - if (isApiError(err)) { - setExportError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - toast.error(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - return; + onError: (error) => { + const message = resolveErrorMessage(error, "导出失败"); + setExportError(message); + toast.error(message); + }, + }); + + const batchMutation = useMutation({ + mutationFn: async (input: { action: BatchAction; comment: string; selectedItems: typeof actionableItems }) => { + const taskIds = input.selectedItems + .map((item) => item.pendingWorkflow?.task?.id ?? null) + .filter((taskId): taskId is string => Boolean(taskId)); + + if (!taskIds.length) { + throw new Error("请先选择至少一条可处理的报销待办。"); } - const msg = err instanceof Error ? err.message : "导出失败"; - setExportError(msg); - toast.error(msg); + + const response = + input.action === "approve" + ? await batchApproveWorkflowTasks({ + taskIds, + comment: input.comment.trim() || undefined, + }) + : await batchRejectWorkflowTasks({ + taskIds, + comment: input.comment.trim(), + }); + + return { + action: input.action, + response, + selectedItems: input.selectedItems.map((item) => ({ + reimbursementId: item.id, + formNumber: item.formNumber, + taskId: item.pendingWorkflow?.task?.id ?? null, + })), + }; + }, + onSuccess: async (payload) => { + const formByTaskId = new Map( + payload.selectedItems + .filter((item) => item.taskId) + .map((item) => [item.taskId!, item]), + ); + + setBatchResult({ + action: payload.action, + summary: payload.response.summary, + results: payload.response.results.map((item) => { + const related = formByTaskId.get(item.taskId); + return { + ...item, + reimbursementId: related?.reimbursementId ?? null, + formNumber: related?.formNumber ?? null, + }; + }), + }); + setBatchDialogAction(null); + setBatchComment(""); + setBatchMode(false); + setSelectedReimbursementIds([]); + setBatchError(""); + await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); + toast.success(payload.action === "approve" ? "批量通过已执行" : "批量驳回已执行"); + }, + onError: (error) => { + const message = resolveErrorMessage(error, "批量处理失败"); + setBatchError(message); + toast.error(message); }, }); const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); + function toggleBatchSelection(reimbursementId: string) { + setSelectedReimbursementIds((current) => + current.includes(reimbursementId) + ? current.filter((id) => id !== reimbursementId) + : [...current, reimbursementId], + ); + } + + function toggleSelectAllActionable() { + if (allActionableSelected) { + setSelectedReimbursementIds([]); + return; + } + setSelectedReimbursementIds(actionableItems.map((item) => item.id)); + } + + function openBatchDialog(action: BatchAction) { + if (!selectedItems.length) { + setBatchError("请先选择至少一条可批量处理的报销待办。"); + return; + } + setBatchError(""); + setBatchDialogAction(action); + } + + function handleCloseBatchDialog(open: boolean) { + if (!open) { + setBatchDialogAction(null); + setBatchComment(""); + } + } + + function submitBatchAction() { + if (!batchDialogAction) { + return; + } + if (batchDialogAction === "reject" && !batchComment.trim()) { + setBatchError("批量驳回必须填写统一理由。"); + return; + } + + batchMutation.mutate({ + action: batchDialogAction, + comment: batchComment, + selectedItems, + }); + } + return ( ) : null} + {canViewAll ? ( + + ) : null} + + + + ) : null} + + {canShowBatchEntry ? ( - -
- ) : null} + ) : null} +
+ + {batchMode ? ( +
+ 当前只可勾选你本人可处理的 reviewing 报销待办。批量通过可不填意见,批量驳回必须填写统一理由。 +
+ ) : null} - {listQuery.isLoading ? : null} + {listQuery.isLoading ? : null} {listQuery.isError ? (

@@ -261,9 +484,10 @@ export function ReimbursementsPage() { {!listQuery.isLoading && !listQuery.isError ? ( <> - +
+ {batchMode ? : null} @@ -274,40 +498,73 @@ export function ReimbursementsPage() { {items.length ? ( - items.map((item) => ( - - - - - + {batchMode ? ( + + ) : null} + + + + - - - - )) + + + + + ); + }) ) : ( - @@ -326,6 +583,118 @@ export function ReimbursementsPage() { ) : null} + + {batchMode ? ( +
+
+
+
已选 {selectedItems.length} 项可处理报销待办
+
+ + + +
+
+
+
+ ) : null} + + + + + {batchDialogAction === "approve" ? "批量通过报销待办" : "批量驳回报销待办"} + + {batchDialogAction === "approve" + ? "统一意见可空。所有选中待办会按当前顺序逐条调用在线审批接口。" + : "驳回必须填写统一理由,所有选中待办将复用这条说明。"} + + + +
+
+ 共 {selectedItems.length} 项: +
+ {selectedItems.map((item) => ( +
{item.formNumber ?? item.id}
+ ))} +
+
+ +
+ +
选择单号 状态 金额
-
{item.formNumber || item.id}
- {item.formNumber && item.formNumber !== item.id && ( -
{item.id}
- )} - {item.project ?
项目:{item.project.projectNo} · {item.project.name}
: null} -
- {item.totalAmount != null - ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(item.totalAmount)) - : "-"} - -
-
- {(item.owner?.name ?? "U").charAt(0)} + items.map((item) => { + const canSelectRow = Boolean( + item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"), + ); + + return ( +
+ {canSelectRow ? ( + toggleBatchSelection(item.id)} + aria-label={`选择报销单 ${item.formNumber ?? item.id}`} + /> + ) : ( + - + )} + +
{item.formNumber || item.id}
+ {item.formNumber && item.formNumber !== item.id ? ( +
{item.id}
+ ) : null} + {item.project ? ( +
+ 项目:{item.project.projectNo} · {item.project.name} +
+ ) : null} + {item.pendingWorkflow?.task?.id ? ( +
+ 当前待办:{item.pendingWorkflow.task.stepName} · task {item.pendingWorkflow.task.id} +
+ ) : null} +
+ + + {item.totalAmount != null + ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format( + Number(item.totalAmount), + ) + : "-"} + +
+
+ {(item.owner?.name ?? "U").charAt(0)} +
+ {item.owner?.name ?? "-"}
- {item.owner?.name ?? "-"} - -
{item.reimbursementDate ?? "-"} - - more_horiz - -
{item.reimbursementDate ?? "-"} + + more_horiz + +
+ 暂无数据