diff --git a/.trellis/tasks/05-30-nav-reimbursement-approval-surface/check.jsonl b/.trellis/tasks/05-30-nav-reimbursement-approval-surface/check.jsonl new file mode 100644 index 00000000..e87c7063 --- /dev/null +++ b/.trellis/tasks/05-30-nav-reimbursement-approval-surface/check.jsonl @@ -0,0 +1,3 @@ +{"file":"docs/产品设计规则.md","reason":"检查实现是否符合小团队入口、审批集中和业务台账边界。"} +{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"检查导航、动作位置、角色收敛和列表默认规则。"} +{"file":".trellis/spec/hyb-oa/wecom-boundary.md","reason":"检查请假/考勤入口仍符合企业微信主办边界。"} diff --git a/.trellis/tasks/05-30-nav-reimbursement-approval-surface/implement.jsonl b/.trellis/tasks/05-30-nav-reimbursement-approval-surface/implement.jsonl new file mode 100644 index 00000000..9f04ddda --- /dev/null +++ b/.trellis/tasks/05-30-nav-reimbursement-approval-surface/implement.jsonl @@ -0,0 +1,3 @@ +{"file":"docs/产品设计规则.md","reason":"产品体验事实源:小团队入口、审批集中、业务台账边界和移动/PC职责。"} +{"file":".trellis/spec/hyb-oa/frontend-react-vite-design.md","reason":"前端设计与导航、动作位置、状态驱动动作、角色收敛规则。"} +{"file":".trellis/spec/hyb-oa/wecom-boundary.md","reason":"确认 HR/考勤与企业微信边界,避免导航调整误放日常请假/考勤写入口。"} diff --git a/.trellis/tasks/05-30-nav-reimbursement-approval-surface/implement.md b/.trellis/tasks/05-30-nav-reimbursement-approval-surface/implement.md new file mode 100644 index 00000000..ff74f387 --- /dev/null +++ b/.trellis/tasks/05-30-nav-reimbursement-approval-surface/implement.md @@ -0,0 +1,32 @@ +# Implementation Notes + +## Decision + +本任务按“业务台账直接可达、审批执行集中处理”的规则落地: + +- `合同台账`、`采购申请`、`项目`、`资产` 是启用且有权限时的日常业务入口,PC 主菜单直接展示。 +- `更多` 继续承载周报、公告、预算、考勤归档、差旅、资产盘点等低频或阶段性入口。 +- 报销列表不直接执行批量通过/批量驳回;审批人从列表进入 `待我处理` 任务详情核对摘要和证据后处理。 + +## Code Surface + +- `server/src/services/workspaceSettings.js` + - 调整默认模块可见性和主菜单排序。 + - 将日常业务模块从 backup/admin 收口到 main,但仍保留权限、feature flag 和 hidden 显式关闭。 +- `server/src/routes/featureFlags.js` + - 同步功能开关影响面里的导航分区判断。 +- `server/src/scripts/seed-dev.js` +- `server/src/scripts/seed-trialrun.js` + - 同步新工作区种子模块位置。 +- `client/src/features/navigation/schema.ts` +- `client/src/components/navigation/NavLinks.tsx` + - 同步 fallback 导航顺序和项目自助权限。 +- `client/src/pages/ReimbursementsPage.tsx` + - 移除列表批量审批状态、按钮、弹窗和结果展示。 + - 保留状态驱动的 `编辑`、`查看处理`、`查看进度`、`详情`。 + +## Verification Focus + +- 普通员工主菜单显示自助业务入口,但不显示 `待我处理`。 +- 审批人/负责人主菜单显示 `待我处理` 和授权业务台账。 +- 报销列表即使存在可处理 workflow task,也只显示 `查看处理`,不显示批量审批执行入口。 diff --git a/.trellis/tasks/05-30-nav-reimbursement-approval-surface/prd.md b/.trellis/tasks/05-30-nav-reimbursement-approval-surface/prd.md new file mode 100644 index 00000000..3f46fff9 --- /dev/null +++ b/.trellis/tasks/05-30-nav-reimbursement-approval-surface/prd.md @@ -0,0 +1,108 @@ +# 导航与报销审批入口收口 + +## Goal + +把已确认的 PC 核心入口规则落到代码:合同、采购、资产、项目等常用业务在有权限且模块启用时直接出现在主菜单;报销列表不再直接执行批量通过/批量驳回,审批动作统一回到 `待我处理` / 审批详情。 + +## Background + +- 目标用户是国内、小于 10 人的科技初创公司,角色重叠、审批链短,入口应少绕路。 +- 用户已确认:合同、采购、资产、项目不默认藏到 `更多`,有权限且启用时直接显示。 +- 用户已确认:报销列表里的批量通过/批量驳回应迁移到 `待我处理`,报销列表只做查看、筛选、进度和补材料入口。 +- 用户已确认:后续选择项使用 `1 / 2 / 3`,不再用 `A / B / C`。 +- 移动端细节单独进入后续 task,本任务只做 PC 导航与 PC 报销审批入口收口。 + +## Scope + +本任务只覆盖 PC 主菜单和 PC 报销列表审批入口收口: + +- 服务端系统导航、默认工作区可见性、试运行/开发种子里的常用业务入口位置。 +- 前端导航 fallback 顺序和本地权限 fallback 口径。 +- PC 报销列表里的批量审批入口、局部状态、确认弹窗和对应前端断言。 +- 导航单元测试、登录/工作台视觉测试、报销列表视觉测试。 + +明确不覆盖: + +- 移动请假额度、移动财务卡、移动搜索文案、移动待办文案。 +- `待我处理` 新增批量审批能力。 +- 服务端审批 API、权限模型、审批状态机、数据库迁移和生产部署。 + +## Current Observations + +- `server/src/services/workspaceSettings.js` 的默认导航仍把 `assets`、`purchaseRequests`、`contractsRegistry` 放入 backup/admin 口径,`resolveNavigationSection` 对 `BACKUP_NAVIGATION_MODULES` 强制进入 backup。 +- `client/src/features/navigation/schema.ts` 的 fallback 顺序仍把 `assets`、`purchase-requests`、`contracts-registry` 放在 `BACKUP_NAV_ORDER`。 +- `client/src/pages/ReimbursementsPage.tsx` 仍保留 `批量操作`、`批量通过`、`批量驳回` 和批量审批确认弹窗。 +- `.trellis/spec/hyb-oa/frontend-react-vite-design.md` 和 `docs/产品设计规则.md` 已规定审批动作集中在 `待我处理` / 审批详情,业务台账只显示 `查看处理`、`查看进度`、`补材料` 等入口。 + +## Requirements + +1. PC 主菜单直接显示常用业务台账。 + - 合同、采购、资产、项目在服务端导航和客户端 fallback 中进入主菜单排序。 + - 仍按权限、feature flag、模块启用状态过滤;不因为主菜单开放而绕过服务端权限。 + - `更多` 继续承载低频、冻结、阶段性或偶尔查看入口。 + +2. 报销列表移除审批执行动作。 + - 移除或隐藏报销列表的 `批量操作`、`批量通过`、`批量驳回` 执行入口。 + - 报销列表保留查看详情、查看处理、查看进度、继续编辑、补材料等状态驱动入口。 + - 不在本任务新增 `待我处理` 批量审批能力;未来如需要,另开 task。 + +3. 测试与文案同步。 + - 更新导航相关单元/E2E 断言。 + - 更新报销列表相关 Playwright 断言,确保报销列表不再出现批量审批执行入口。 + - 若规范已有明确规则,只补充与本任务相关的稳定实现约束,不重复写视觉规则。 + +## Acceptance Criteria + +- [x] 有权限且模块启用的用户在 PC 主菜单可直接看到 `资产`、`采购申请`、`合同台账`、`项目`。 +- [x] 普通员工不显示无权限或未启用模块;审批人仍按规则显示 `待我处理`。 +- [x] 报销列表不显示 `批量操作`、`批量通过`、`批量驳回`。 +- [x] 报销列表仍可进入报销详情或相应处理/进度入口。 +- [x] `待我处理` 作为审批动作执行入口的规则保持一致。 +- [x] 前端 typecheck/build 通过,导航/报销相关测试通过。 +- [x] Trellis governance 和 `git diff --check` 通过。 + +## Test Plan + +- `cd server && node --check src/services/workspaceSettings.js && node --check src/routes/featureFlags.js && node --check src/scripts/seed-dev.js && node --check src/scripts/seed-trialrun.js` +- `cd server && npm test -- workspaceSettings.navigation.test.js` +- `cd client && npm run typecheck` +- `cd client && npm run build` +- 启动本地 Vite 后运行:`cd client && npx playwright test tests/e2e/visual-shell.spec.ts tests/e2e/login-portal-ux.spec.ts --workers=1 --reporter=list` +- `python3 ./.trellis/scripts/task.py validate 05-30-nav-reimbursement-approval-surface` +- `node scripts/qa/check-trellis-governance.mjs` +- `git diff --check` + +## Out Of Scope + +- 不实现 `待我处理` 的批量审批能力。 +- 不调整移动请假额度、移动财务卡、移动搜索文案和移动 `待我处理` 文案;这些进入第二个 task。 +- 不调整生产部署、不做公网检查、不写真实企业微信或生产数据。 +- 不改变服务端权限模型、审批状态机或报销审批 API 能力。 + +## Implementation Plan + +1. 调整导航模型。 + - 服务端:更新默认模块可见性、backup 模块集合和主菜单排序,让合同、采购、资产、项目按权限和 feature flag 进入主菜单。 + - 前端 fallback:同步 `MAIN_NAV_ORDER` / `BACKUP_NAV_ORDER`,避免导航接口异常时回退到旧入口结构。 + +2. 收口报销列表审批动作。 + - 删除或关闭报销列表批量审批入口和确认弹窗展示。 + - 保留必要的查看、进度和编辑入口;如存在未使用批量逻辑,优先清理掉页面局部状态和 mutation。 + +3. 更新测试。 + - 更新 `workspaceSettings.navigation.test.js` 对默认主菜单和角色主菜单的期望。 + - 更新报销相关 E2E,断言列表不出现批量审批执行按钮,保留业务列表能力。 + +4. 验证。 + - `cd server && npm test -- workspaceSettings.navigation.test.js` 或项目可用的聚焦单元测试命令。 + - `cd client && npm run typecheck` + - `cd client && npm run build` + - 相关 Playwright 聚焦测试。 + - `python3 ./.trellis/scripts/task.py validate 05-30-nav-reimbursement-approval-surface` + - `node scripts/qa/check-trellis-governance.mjs` + - `git diff --check` + +## Notes + +- 本任务遵循 `frontend-design` 对前端体验的裁决:这是小团队中文 OA 的任务型工作台,不做全模块目录,也不把高风险执行动作铺在普通列表首屏。 +- 页面视觉细节以现有共享组件和当前设计系统为准,不在本任务引入新的视觉体系。 diff --git a/.trellis/tasks/05-30-nav-reimbursement-approval-surface/task.json b/.trellis/tasks/05-30-nav-reimbursement-approval-surface/task.json new file mode 100644 index 00000000..e781fc1c --- /dev/null +++ b/.trellis/tasks/05-30-nav-reimbursement-approval-surface/task.json @@ -0,0 +1,26 @@ +{ + "id": "nav-reimbursement-approval-surface", + "name": "nav-reimbursement-approval-surface", + "title": "导航与报销审批入口收口", + "description": "PC 主菜单直接开放常用业务入口,并将报销列表审批执行动作收口到待我处理。", + "status": "in_progress", + "dev_type": "frontend-backend", + "scope": "pc-navigation-reimbursement-approval-surface", + "package": "hyb-oa", + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-05-30", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index bac7e915..72849e61 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -26,7 +26,7 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/assets", label: "资产", icon: "inventory_2", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] }, { to: "/weekly-reports", label: "周报", icon: "article" }, { to: "/announcements", label: "公告", icon: "campaign" }, - { to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_all", "project.manage"] }, + { to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] }, { to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] }, { to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] }, { to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] }, diff --git a/client/src/features/navigation/schema.ts b/client/src/features/navigation/schema.ts index f58486f4..5da25f96 100644 --- a/client/src/features/navigation/schema.ts +++ b/client/src/features/navigation/schema.ts @@ -1,6 +1,6 @@ -export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/payment-batches", "/ap-operations", "/invoice-pool", "/projects", "/team", "/leave-requests"]; +export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/purchase-requests", "/contracts-registry", "/projects", "/assets", "/payment-batches", "/ap-operations", "/invoice-pool", "/team", "/leave-requests"]; export const ADMIN_NAV_ORDER = ["/system/settings"]; -export const BACKUP_NAV_ORDER = ["/assets", "/weekly-reports", "/announcements", "/purchase-requests", "/contracts-registry", "/budgets", "/attendance", "/wecom-approval-archive", "/travel-requests", "/inventory-checks", "/dashboard"]; +export const BACKUP_NAV_ORDER = ["/weekly-reports", "/announcements", "/budgets", "/attendance", "/wecom-approval-archive", "/travel-requests", "/inventory-checks", "/dashboard"]; const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [ { path: "/portal", title: "工作台", group: "工作台" }, diff --git a/client/src/pages/ReimbursementsPage.tsx b/client/src/pages/ReimbursementsPage.tsx index dd275299..6389fc04 100644 --- a/client/src/pages/ReimbursementsPage.tsx +++ b/client/src/pages/ReimbursementsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react"; @@ -16,11 +16,6 @@ import { type ReimbursementListItem, 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"; @@ -32,14 +27,6 @@ import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { Button } from "../components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "../components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -48,24 +35,11 @@ import { 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 ExportFormat = "csv" | "xlsx"; -type BatchResultState = { - action: BatchAction; - summary: WorkflowBatchActionResponse["summary"]; - results: Array< - WorkflowBatchActionResponse["results"][number] & { - reimbursementId: string | null; - formNumber: string | null; - } - >; -}; - const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: string }> = [ { value: "draft", label: "草稿" }, { value: "rejected", label: "被驳回" }, @@ -120,22 +94,6 @@ function resolveErrorMessage(error: unknown, fallback: string) { 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" ? "已通过" : "已驳回"; -} - function resolveEmptyStateCopy(status: ReimbursementStatus) { if (status === "draft") { return { @@ -186,19 +144,41 @@ function resolveWorkflowHint(item: ReimbursementListItem) { }; } +function resolveListAction(item: ReimbursementListItem) { + if (["draft", "rejected"].includes(item.status)) { + return { + label: "编辑", + mobileLabel: "继续编辑", + to: `/reimbursements/${item.id}`, + primary: true, + }; + } + + const taskId = item.pendingWorkflow?.task?.id; + if (taskId && item.pendingWorkflow?.allowedActions.length) { + return { + label: "查看处理", + mobileLabel: "查看处理", + to: `/workflow/tasks/${taskId}`, + primary: false, + }; + } + + return { + label: item.status === "reviewing" ? "查看进度" : "详情", + mobileLabel: item.status === "reviewing" ? "查看进度" : "查看详情", + to: `/reimbursements/${item.id}`, + primary: false, + }; +} + export function ReimbursementsPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); 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 [deleteTarget, setDeleteTarget] = useState(null); const [exportConfirmFormat, setExportConfirmFormat] = useState(null); const [showLedgerTools, setShowLedgerTools] = useState(false); @@ -209,7 +189,6 @@ export function ReimbursementsPage() { const canCreateExpense = hasPermission(permissions, "expense.create"); const canDeleteExpense = hasPermission(permissions, "expense.delete_own"); const canViewAll = hasPermission(permissions, "expense.view_all"); - const canApproveExpense = hasPermission(permissions, "expense.approve"); const canProxySubmit = hasPermission(permissions, "expense.proxy_submit"); const canUseFinanceTools = hasPermission(permissions, "expense.finance.pay") || @@ -219,7 +198,7 @@ export function ReimbursementsPage() { const isAdministration = (me?.roleCodes ?? []).includes("administration"); const showProxyOwnerSelector = canProxySubmit && isAdministration; const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools; - const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector); + const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || showProxyOwnerSelector); const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense; const listQuery = useQuery({ @@ -280,24 +259,6 @@ export function ReimbursementsPage() { const templateOptions = templateOptionsQuery.data?.items ?? []; const linkableTravelRequests = linkableTravelRequestsQuery.data?.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)); const taskSummaryTotalByStatus = { rejected: rejectedSummaryQuery.data?.total, draft: draftSummaryQuery.data?.total, @@ -307,14 +268,6 @@ export function ReimbursementsPage() { canCreateExpense && shouldLoadCreateOptions && (templateOptions.length > 0 || linkableTravelRequests.length > 0); const emptyState = resolveEmptyStateCopy(status); - useEffect(() => { - setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id))); - if (!canShowBatchEntry) { - setBatchMode(false); - setBatchDialogAction(null); - } - }, [actionableIdSet, canShowBatchEntry]); - const createMutation = useMutation({ mutationFn: (payload: ReimbursementCreatePayload) => createReimbursement(payload), onSuccess: async (response, payload) => { @@ -362,121 +315,8 @@ export function ReimbursementsPage() { }, }); - 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 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, - }); - } - function startCreateReimbursement(payload: Omit = {}) { createMutation.mutate({ ownerEmployeeId: proxyOwnerEmployeeId || undefined, @@ -520,12 +360,6 @@ export function ReimbursementsPage() {

) : null} - {batchError ? ( -

- {batchError} -

- ) : null} -
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => { const Icon = item.icon; @@ -748,34 +582,12 @@ export function ReimbursementsPage() { - {canShowBatchEntry ? ( - - ) : null} ) : null} - - {batchMode ? ( -
- 仅勾选你可处理的审批中报销;驳回需填写理由。 -
- ) : null} - {listQuery.isLoading ? : null} + {listQuery.isLoading ? : null} {listQuery.isError ? (

@@ -794,10 +606,7 @@ export function ReimbursementsPage() {

{items.length ? ( items.map((item) => { - const canSelectRow = Boolean( - item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"), - ); - const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情"; + const listAction = resolveListAction(item); const displayCode = resolveReimbursementDisplayCode(item); const canDeleteDraft = item.status === "draft" && canDeleteExpense; const workflowHint = resolveWorkflowHint(item); @@ -848,29 +657,17 @@ export function ReimbursementsPage() { ) : null}
- {batchMode ? ( - - ) : null} {canDeleteDraft ? ( @@ -912,7 +709,6 @@ export function ReimbursementsPage() { - {batchMode ? : null} @@ -924,29 +720,13 @@ export function ReimbursementsPage() { {items.length ? ( items.map((item) => { - const canSelectRow = Boolean( - item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"), - ); + const listAction = resolveListAction(item); const displayCode = resolveReimbursementDisplayCode(item); const canDeleteDraft = item.status === "draft" && canDeleteExpense; const workflowHint = resolveWorkflowHint(item); return ( - {batchMode ? ( - - ) : null} -
选择单号 状态 金额
- {canSelectRow ? ( - toggleBatchSelection(item.id)} - aria-label={`选择报销单 ${displayCode}`} - /> - ) : ( - - - )} -
{displayCode}
{item.project ? ( @@ -984,13 +764,13 @@ export function ReimbursementsPage() { {canDeleteDraft ? ( @@ -1022,7 +802,7 @@ export function ReimbursementsPage() { }) ) : (
+
{emptyState.title}
{emptyState.description}
@@ -1056,118 +836,6 @@ export function ReimbursementsPage() { ) : null} - {batchMode ? ( -
-
-
-
已选 {selectedItems.length} 项可处理报销待办
-
- - - -
-
-
-
- ) : null} - - - - - {batchDialogAction === "approve" ? "批量通过报销待办" : "批量驳回报销待办"} - - {batchDialogAction === "approve" - ? "统一意见可空。所有选中待办会按当前顺序逐条调用在线审批接口。" - : "驳回必须填写统一理由,所有选中待办将复用这条说明。"} - - - -
-
- 共 {selectedItems.length} 项: -
- {selectedItems.map((item) => ( -
{resolveReimbursementDisplayCode(item)}
- ))} -
-
- -
- -