收口导航与报销审批入口(05-30-nav-reimbursement-approval-surface)

这个提交包含在:
2026-05-30 14:39:13 +08:00
父节点 caf62657f3
当前提交 a0db9c3dbe
共修改 15 个文件,包含 345 行新增和 434 行删除
@@ -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":"检查请假/考勤入口仍符合企业微信主办边界。"}
@@ -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/考勤与企业微信边界,避免导航调整误放日常请假/考勤写入口。"}
@@ -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,也只显示 `查看处理`,不显示批量审批执行入口。
@@ -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 的任务型工作台,不做全模块目录,也不把高风险执行动作铺在普通列表首屏。
- 页面视觉细节以现有共享组件和当前设计系统为准,不在本任务引入新的视觉体系。
@@ -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": {}
}
@@ -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"] },
+2 -2
查看文件
@@ -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: "工作台" },
+40 -372
查看文件
@@ -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<string[]>([]);
const [batchDialogAction, setBatchDialogAction] = useState<BatchAction | null>(null);
const [batchComment, setBatchComment] = useState("");
const [batchResult, setBatchResult] = useState<BatchResultState | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(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<ReimbursementCreatePayload, "ownerEmployeeId"> = {}) {
createMutation.mutate({
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
@@ -520,12 +360,6 @@ export function ReimbursementsPage() {
</p>
) : null}
{batchError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{batchError}
</p>
) : null}
<section className="grid gap-3 md:grid-cols-3" aria-label="我的报销任务">
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => {
const Icon = item.icon;
@@ -748,34 +582,12 @@ export function ReimbursementsPage() {
</DropdownMenuContent>
</DropdownMenu>
{canShowBatchEntry ? (
<Button
size="sm"
variant={batchMode ? "default" : "secondary"}
onClick={() => {
setBatchMode((current) => !current);
setSelectedReimbursementIds([]);
setBatchDialogAction(null);
setBatchComment("");
setBatchError("");
}}
type="button"
>
{batchMode ? "退出批量模式" : "批量操作"}
</Button>
) : null}
</div>
</div>
) : null}
{batchMode ? (
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800">
仅勾选你可处理的审批中报销;驳回需填写理由。
</div>
) : null}
</FilterBar>
{listQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={5} /> : null}
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
@@ -794,10 +606,7 @@ export function ReimbursementsPage() {
<div className="space-y-3 md:hidden" data-testid="reimbursements-mobile-list">
{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}
<div className="flex items-center gap-2 pt-1">
{batchMode ? (
<label className="flex min-h-10 items-center gap-2 rounded-lg border border-zinc-200 px-3 text-sm text-zinc-700">
<input
type="checkbox"
checked={selectedReimbursementIds.includes(item.id)}
disabled={!canSelectRow}
onChange={() => toggleBatchSelection(item.id)}
aria-label={`选择报销单 ${displayCode}`}
/>
选择
</label>
) : null}
<Button
asChild
className="min-h-11 flex-1"
size="sm"
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
variant={listAction.primary ? "default" : "secondary"}
>
<Link
to={`/reimbursements/${item.id}`}
to={listAction.to}
data-testid="reimbursement-mobile-detail-link"
>
{actionLabel}
{listAction.mobileLabel}
</Link>
</Button>
{canDeleteDraft ? (
@@ -912,7 +709,6 @@ export function ReimbursementsPage() {
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
<thead className="table-header">
<tr>
{batchMode ? <th className="table-th w-14">选择</th> : null}
<th className="table-th">单号</th>
<th className="table-th">状态</th>
<th className="table-th">金额</th>
@@ -924,29 +720,13 @@ export function ReimbursementsPage() {
<tbody className="divide-y divide-zinc-50">
{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 (
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
{batchMode ? (
<td className="table-td">
{canSelectRow ? (
<input
type="checkbox"
checked={selectedReimbursementIds.includes(item.id)}
onChange={() => toggleBatchSelection(item.id)}
aria-label={`选择报销单 ${displayCode}`}
/>
) : (
<span className="text-xs text-zinc-400">-</span>
)}
</td>
) : null}
<td className="table-td">
<div className="font-semibold text-zinc-900">{displayCode}</div>
{item.project ? (
@@ -984,13 +764,13 @@ export function ReimbursementsPage() {
<Button
asChild
size="sm"
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
variant={listAction.primary ? "default" : "secondary"}
>
<Link
to={`/reimbursements/${item.id}`}
to={listAction.to}
data-testid="reimbursement-detail-link"
>
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
{listAction.label}
</Link>
</Button>
{canDeleteDraft ? (
@@ -1022,7 +802,7 @@ export function ReimbursementsPage() {
})
) : (
<tr>
<td className="py-8 text-center text-sm text-zinc-500" colSpan={batchMode ? 7 : 6}>
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
<div className="mx-auto max-w-sm py-3">
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
@@ -1056,118 +836,6 @@ export function ReimbursementsPage() {
</>
) : null}
{batchMode ? (
<div className="fixed bottom-6 left-1/2 z-40 w-[min(760px,calc(100vw-2rem))] -translate-x-1/2">
<div className="rounded-lg border border-zinc-200 bg-white p-3.5 shadow-lg">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-zinc-700">已选 {selectedItems.length} 项可处理报销待办</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" onClick={toggleSelectAllActionable}>
{allActionableSelected ? "取消全选" : "全选可处理项"}
</Button>
<Button type="button" variant="secondary" onClick={() => openBatchDialog("approve")}>
批量通过
</Button>
<Button type="button" variant="destructive" onClick={() => openBatchDialog("reject")}>
批量驳回
</Button>
</div>
</div>
</div>
</div>
) : null}
<Dialog open={Boolean(batchDialogAction)} onOpenChange={handleCloseBatchDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{batchDialogAction === "approve" ? "批量通过报销待办" : "批量驳回报销待办"}</DialogTitle>
<DialogDescription>
{batchDialogAction === "approve"
? "统一意见可空。所有选中待办会按当前顺序逐条调用在线审批接口。"
: "驳回必须填写统一理由,所有选中待办将复用这条说明。"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3.5 text-sm text-zinc-700">
共 {selectedItems.length} 项:
<div className="mt-2 max-h-40 space-y-1 overflow-auto">
{selectedItems.map((item) => (
<div key={item.id}>{resolveReimbursementDisplayCode(item)}</div>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-900" htmlFor="reimbursements-batch-comment">
{batchDialogAction === "reject" ? "统一驳回理由" : "统一审批意见"}
</label>
<Textarea
id="reimbursements-batch-comment"
value={batchComment}
onChange={(event) => setBatchComment(event.target.value)}
placeholder={batchDialogAction === "reject" ? "请输入批量驳回理由" : "可选填写统一审批意见"}
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button variant="secondary" type="button" onClick={() => handleCloseBatchDialog(false)}>
取消
</Button>
<Button
type="button"
variant={batchDialogAction === "reject" ? "destructive" : "default"}
disabled={batchMutation.isPending || (batchDialogAction === "reject" && !batchComment.trim())}
onClick={submitBatchAction}
>
{batchMutation.isPending
? "处理中..."
: batchDialogAction === "approve"
? "确认批量通过"
: "确认批量驳回"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(batchResult)} onOpenChange={(open) => !open && setBatchResult(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{batchResult?.action === "approve" ? "批量通过结果" : "批量驳回结果"}</DialogTitle>
<DialogDescription>
{batchResult
? `成功 ${batchResult.summary.approved ?? 0},驳回 ${batchResult.summary.rejected ?? 0},跳过 ${batchResult.summary.skipped},失败 ${batchResult.summary.errored}`
: "查看批量操作逐条执行结果。"}
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-2 overflow-auto rounded-lg border border-zinc-200 bg-zinc-50 p-3.5">
{batchResult?.results.map((item) => (
<div key={`${item.taskId}-${item.outcome}`} className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="font-medium text-zinc-900">
{resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: item.reimbursementId })}
</div>
</div>
<div className="text-sm font-medium text-zinc-700">
{formatBatchOutcome(batchResult.action, item.outcome)}
</div>
</div>
{item.reason ? <div className="mt-2 text-sm text-red-600">{item.reason}</div> : null}
</div>
))}
</div>
<DialogFooter>
<Button type="button" onClick={() => setBatchResult(null)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ActionConfirmDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
+7 -4
查看文件
@@ -184,7 +184,10 @@ function mockNavigation(page: import("@playwright/test").Page, mode: "employee"
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "workflow", href: "/workflow/tasks", label: "待我处理", icon: "fact_check", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "purchase", href: "/purchase-requests", label: "采购申请", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "contracts", href: "/contracts-registry", label: "合同台账", icon: "description", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "team", href: "/team", label: "团队", icon: "groups", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "leave", href: "/leave-requests", label: "请假记录", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
],
@@ -192,22 +195,22 @@ function mockNavigation(page: import("@playwright/test").Page, mode: "employee"
{ key: "settings", href: "/system/settings", label: "管理", icon: "settings", placement: "admin", permissionCodes: [], featureFlagKey: null },
],
backup: [
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "backup", permissionCodes: [], featureFlagKey: null },
{ key: "weekly", href: "/weekly-reports", label: "周报", icon: "article", placement: "backup", permissionCodes: [], featureFlagKey: null },
{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "backup", permissionCodes: [], featureFlagKey: null },
{ key: "purchase", href: "/purchase-requests", label: "采购申请", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "travel", href: "/travel-requests", label: "差旅申请", icon: "flight", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "travel", href: "/travel-requests", label: "差旅申请", icon: "flight", placement: "backup", permissionCodes: [], featureFlagKey: null },
],
}
: {
main: [
{ key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "purchase", href: "/purchase-requests", label: "采购申请", icon: "shopping_cart", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "projects", href: "/projects", label: "项目", icon: "workspaces", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "leave", href: "/leave-requests", label: "请假记录", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null },
],
admin: [],
backup: [
{ key: "assets", href: "/assets", label: "资产", icon: "inventory_2", placement: "backup", permissionCodes: [], featureFlagKey: null },
{ key: "weekly", href: "/weekly-reports", label: "周报", icon: "article", placement: "backup", permissionCodes: [], featureFlagKey: null },
{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "backup", permissionCodes: [], featureFlagKey: null },
],
+90 -34
查看文件
@@ -117,8 +117,12 @@ async function mockVisualApis(page: Page) {
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"),
navigationItem("approvals", "/workflow/tasks", "待我处理", "fact_check"),
navigationItem("reimbursements", "/reimbursements", "报销", "request_quote"),
navigationItem("purchase", "/purchase-requests", "采购申请", "shopping_cart"),
navigationItem("contracts", "/contracts-registry", "合同台账", "description"),
navigationItem("projects", "/projects", "项目", "workspaces"),
navigationItem("assets", "/assets", "资产", "inventory_2"),
],
admin: [],
backup: [navigationItem("invoice", "/invoice-pool", "票据", "receipt_long")],
@@ -239,42 +243,82 @@ async function mockVisualApis(page: Page) {
}
if (path === "/api/reimbursements") {
const requestedStatus = url.searchParams.get("status");
const reviewingItem = {
id: "rb-r96-reviewing",
formNumber: "BX20260506002",
status: "reviewing",
reimbursementDate: "2026-05-06",
totalAmount: "320.00",
description: "待处理报销",
payment: {
paymentVoucherNo: null,
paymentDate: null,
paymentChannel: null,
paidAt: null,
paidBy: null,
},
owner: {
id: "employee-r96",
employeeNo: "R96001",
name: "Employee",
},
submitter: {
id: "employee-r96",
employeeNo: "R96001",
name: "Employee",
},
project: null,
pendingWorkflow: {
task: {
id: "task-r96",
stepName: "经理审批",
assigneeEmployeeId: "employee-r96",
assigneeRoleCode: "manager",
status: "pending",
createdAt: "2026-05-06T09:00:00.000Z",
},
allowedActions: ["approve", "reject"],
},
createdAt: "2026-05-06T09:00:00.000Z",
updatedAt: "2026-05-06T09:00:00.000Z",
};
const draftItem = {
id: "rb-r96",
formNumber: "BX20260506001",
status: "draft",
reimbursementDate: "2026-05-06",
totalAmount: "168.30",
description: "视觉基线报销",
payment: {
paymentVoucherNo: null,
paymentDate: null,
paymentChannel: null,
paidAt: null,
paidBy: null,
},
owner: {
id: "employee-r96",
employeeNo: "R96001",
name: "Employee",
},
submitter: {
id: "employee-r96",
employeeNo: "R96001",
name: "Employee",
},
project: null,
pendingWorkflow: null,
createdAt: "2026-05-06T09:00:00.000Z",
updatedAt: "2026-05-06T09:00:00.000Z",
};
const items = requestedStatus === "reviewing" ? [reviewingItem] : [draftItem];
await fulfillJson(route, {
page: 1,
pageSize: 10,
total: 1,
scope: "own",
items: [
{
id: "rb-r96",
formNumber: "BX20260506001",
status: "draft",
reimbursementDate: "2026-05-06",
totalAmount: "168.30",
description: "视觉基线报销",
payment: {
paymentVoucherNo: null,
paymentDate: null,
paymentChannel: null,
paidAt: null,
paidBy: null,
},
owner: {
id: "employee-r96",
employeeNo: "R96001",
name: "Employee",
},
submitter: {
id: "employee-r96",
employeeNo: "R96001",
name: "Employee",
},
project: null,
pendingWorkflow: null,
createdAt: "2026-05-06T09:00:00.000Z",
updatedAt: "2026-05-06T09:00:00.000Z",
},
],
total: items.length,
scope: url.searchParams.get("scope") ?? "own",
items,
});
return;
}
@@ -323,6 +367,10 @@ test.describe("visual shell", () => {
await expect(page.locator("aside")).toBeVisible();
await expect(page.getByTestId("topbar-profile-trigger")).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "工作台", exact: true })).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "采购申请" })).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "合同台账" })).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "项目" })).toBeVisible();
await expect(page.locator("aside").getByRole("link", { name: "资产" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "更多" })).toBeVisible();
await attachScreenshot(page, testInfo, "app-shell.png");
@@ -346,7 +394,15 @@ test.describe("visual shell", () => {
await expect(page.getByTestId("reimbursements-export")).not.toBeVisible();
await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible();
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await expect(page.getByRole("button", { name: "批量操作" })).toHaveCount(0);
await attachScreenshot(page, testInfo, "pc-reimbursements-wave1.png");
await page.goto("/reimbursements?status=reviewing&scope=all");
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await expect(page.getByRole("link", { name: "查看处理" })).toBeVisible();
await expect(page.getByRole("button", { name: "批量操作" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "批量通过" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "批量驳回" })).toHaveCount(0);
});
test("移动首页任务化布局基线", async ({ page }, testInfo) => {
-1
查看文件
@@ -124,7 +124,6 @@ const FEATURE_FLAG_SNAPSHOT_SCHEMA = {
};
const BACKUP_NAVIGATION_KEYS = new Set([
"purchaseRequests",
"travelRequests",
"budgets",
"paymentBatches",
+2 -1
查看文件
@@ -310,7 +310,8 @@ async function ensureWorkspaceSetting(prisma) {
systemSettings: "admin",
featureFlags: "admin",
auditLogs: "admin",
purchaseRequests: "admin",
contractsRegistry: "main",
purchaseRequests: "main",
travelRequests: "admin",
budgets: "admin",
paymentBatches: "admin",
+4 -2
查看文件
@@ -157,7 +157,8 @@ async function ensureWorkspaceSetting(prisma) {
systemSettings: "admin",
featureFlags: "admin",
auditLogs: "admin",
purchaseRequests: "admin",
contractsRegistry: "main",
purchaseRequests: "main",
travelRequests: "admin",
budgets: "admin",
paymentBatches: "admin",
@@ -191,7 +192,8 @@ async function ensureWorkspaceSetting(prisma) {
systemSettings: "admin",
featureFlags: "admin",
auditLogs: "admin",
purchaseRequests: "admin",
contractsRegistry: "main",
purchaseRequests: "main",
travelRequests: "admin",
budgets: "admin",
paymentBatches: "admin",
+13 -11
查看文件
@@ -17,6 +17,7 @@ const FINANCE_ROLE_CODES = new Set(["finance", "finance_mgr"]);
const TEAM_NAVIGATION_ROLE_CODES = new Set(["manager", "ceo", "finance_mgr", "hr", "admin"]);
const FINANCE_PERMISSION_CODES = Object.freeze(["payment.batch.manage", "risk.review.manage", "expense.finance.pay"]);
const FINANCE_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations", "invoicePool"]);
const DAY_TO_DAY_BUSINESS_MODULES = new Set(["assets", "contractsRegistry", "purchaseRequests", "projects"]);
const MANAGEMENT_CHILD_MODULES = new Set([
"workspace",
"people",
@@ -33,11 +34,8 @@ const MANAGEMENT_CHILD_MODULES = new Set([
"erpIntegrations",
]);
const BACKUP_NAVIGATION_MODULES = new Set([
"assets",
"weeklyReports",
"announcements",
"contractsRegistry",
"purchaseRequests",
"travelRequests",
"budgets",
"inventoryChecks",
@@ -48,20 +46,20 @@ const NAVIGATION_SECTION_ORDER = Object.freeze({
"portal",
"workflowTasks",
"reimbursements",
"purchaseRequests",
"contractsRegistry",
"projects",
"assets",
"paymentBatches",
"apOperations",
"invoicePool",
"projects",
"team",
"leaveRequests",
],
admin: ["systemSettings"],
backup: [
"assets",
"weeklyReports",
"announcements",
"purchaseRequests",
"contractsRegistry",
"budgets",
"attendance",
"travelRequests",
@@ -80,7 +78,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
weeklyReports: { key: "weeklyReports", label: "周报", icon: "article", href: "/weekly-reports", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.weeklyReport },
todos: { key: "todos", label: "待办", icon: "check_circle", href: "/todos", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.todoBoard },
announcements: { key: "announcements", label: "公告", icon: "campaign", href: "/announcements", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.announcements },
projects: { key: "projects", label: "项目", icon: "workspaces", href: "/projects", anyOf: ["project.view_all", "project.manage"], featureFlagKey: FEATURE_FLAG_KEYS.projectDomain },
projects: { key: "projects", label: "项目", icon: "workspaces", href: "/projects", anyOf: ["project.view_own", "project.view_all", "project.manage"], featureFlagKey: FEATURE_FLAG_KEYS.projectDomain },
workspace: { key: "workspace", label: "工作区设置", icon: "tune", href: "/system/workspace", anyOf: ["system.settings"] },
people: { key: "people", label: "人员管理", icon: "group", href: "/people", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
@@ -116,7 +114,7 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
leaveRequests: "main",
attendance: "hidden",
reimbursements: "main",
assets: "admin",
assets: "main",
weeklyReports: "admin",
todos: "hidden",
announcements: "admin",
@@ -125,7 +123,7 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
workspace: "admin",
people: "admin",
organization: "admin",
contractsRegistry: "admin",
contractsRegistry: "main",
workflowDefinitions: "admin",
systemSettings: "admin",
featureFlags: "admin",
@@ -138,7 +136,7 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
notificationChannels: "admin",
workflowTasks: "admin",
dashboard: "hidden",
purchaseRequests: "admin",
purchaseRequests: "main",
travelRequests: "admin",
budgets: "admin",
paymentBatches: "admin",
@@ -282,6 +280,10 @@ function resolveNavigationSection({ moduleKey, placement, financeNavigation }) {
return financeNavigation ? "main" : null;
}
if (DAY_TO_DAY_BUSINESS_MODULES.has(moduleKey)) {
return "main";
}
if (BACKUP_NAVIGATION_MODULES.has(moduleKey)) {
return "backup";
}
@@ -65,10 +65,15 @@ describe("workspace small-team navigation defaults", () => {
"team",
"leaveRequests",
"reimbursements",
"assets",
"projects",
"contractsRegistry",
"purchaseRequests",
]);
expect(DEFAULT_MODULE_VISIBILITY.todos).toBe("hidden");
expect(DEFAULT_MODULE_VISIBILITY.assets).toBe("admin");
expect(DEFAULT_MODULE_VISIBILITY.assets).toBe("main");
expect(DEFAULT_MODULE_VISIBILITY.contractsRegistry).toBe("main");
expect(DEFAULT_MODULE_VISIBILITY.purchaseRequests).toBe("main");
expect(DEFAULT_MODULE_VISIBILITY.attendance).toBe("hidden");
});
@@ -103,19 +108,19 @@ describe("workspace small-team navigation defaults", () => {
const navigation = await getSystemNavigation({
prisma: buildPrisma(["presence_board_v1", "project_domain_v1"]),
config: buildConfig(),
config: { ...buildConfig(), projectDomainEnabled: true },
employee,
hasPermission: (candidate, permissionCode) => hasPermission(candidate.permissionCodes, permissionCode),
});
const mainLabels = navigation.sections.main.map((item) => item.label);
expect(mainLabels).toEqual(["工作台", "报销", "请假记录"]);
expect(mainLabels).toEqual(["工作台", "报销", "采购申请", "项目", "资产", "请假记录"]);
expect(mainLabels).not.toContain("待我处理");
expect(mainLabels).not.toContain("票据");
expect(mainLabels).not.toContain("个人设置");
const backupKeys = navigation.sections.backup.map((item) => item.key);
expect(backupKeys).toContain("purchaseRequests");
expect(backupKeys).not.toContain("purchaseRequests");
expect(backupKeys).not.toContain("travelRequests");
expect(backupKeys).not.toContain("invoicePool");
expect(backupKeys).not.toContain("contractsRegistry");
@@ -135,8 +140,8 @@ describe("workspace small-team navigation defaults", () => {
};
const managerNavigation = await getSystemNavigation({
prisma: buildPrisma(["presence_board_v1", "project_domain_v1"]),
config: { ...buildConfig(), projectDomainEnabled: true, presenceBoardEnabled: true },
prisma: buildPrisma(["presence_board_v1", "project_domain_v1", "project_linking_v1"]),
config: { ...buildConfig(), projectDomainEnabled: true, projectLinkingEnabled: true, presenceBoardEnabled: true },
employee: manager,
hasPermission: (candidate, permissionCode) => hasPermission(candidate.permissionCodes, permissionCode),
});
@@ -145,7 +150,10 @@ describe("workspace small-team navigation defaults", () => {
"工作台",
"待我处理",
"报销",
"采购申请",
"合同台账",
"项目",
"资产",
"团队",
"请假记录",
]);