增加移动报销查看范围(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -160,17 +160,24 @@ export function MobileReimbursementsPage() {
|
||||
const createIntentHandledRef = useRef(false);
|
||||
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
||||
const scope: "own" | "all" = canViewAllExpense && searchParams.get("scope") === "all" ? "all" : "own";
|
||||
const currentMobileSource = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("intent");
|
||||
params.set("status", status);
|
||||
if (canViewAllExpense) {
|
||||
params.set("scope", scope);
|
||||
} else {
|
||||
params.delete("scope");
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${location.pathname}${query ? `?${query}` : ""}`;
|
||||
}, [location.pathname, location.search, status]);
|
||||
}, [canViewAllExpense, location.pathname, location.search, scope, status]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["mobile", "reimbursements", status],
|
||||
queryFn: () => fetchReimbursements({ status, page: 1, pageSize: 20, scope: "own" }),
|
||||
queryKey: ["mobile", "reimbursements", status, scope],
|
||||
queryFn: () => fetchReimbursements({ status, page: 1, pageSize: 20, scope }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
@@ -203,6 +210,16 @@ export function MobileReimbursementsPage() {
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
|
||||
function handleScopeChange(value: string) {
|
||||
if (!canViewAllExpense || (value !== "own" && value !== "all")) {
|
||||
return;
|
||||
}
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete("intent");
|
||||
nextParams.set("scope", value);
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("intent") !== "create" || !canCreateExpense || createIntentHandledRef.current) {
|
||||
return;
|
||||
@@ -293,6 +310,21 @@ export function MobileReimbursementsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mobile-command-surface">
|
||||
{canViewAllExpense ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-zinc-500">查看范围</div>
|
||||
<Tabs value={scope} onValueChange={handleScopeChange}>
|
||||
<TabsList className="grid h-auto min-h-12 w-full grid-cols-2" aria-label="报销查看范围">
|
||||
<TabsTrigger className="min-h-12" data-testid="mobile-reimbursements-scope-own" value="own">
|
||||
我的
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="min-h-12" data-testid="mobile-reimbursements-scope-all" value="all">
|
||||
全部
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="text-xs font-medium text-zinc-500" htmlFor="mobile-reimbursements-current-search">
|
||||
当前列表搜索
|
||||
</label>
|
||||
@@ -326,7 +358,7 @@ export function MobileReimbursementsPage() {
|
||||
) : null}
|
||||
{visibleItems.map((item) => (
|
||||
<ReimbursementCard
|
||||
canDeleteDraft={canDeleteExpense && item.status === "draft"}
|
||||
canDeleteDraft={canDeleteExpense && scope === "own" && item.status === "draft"}
|
||||
currentMobileSource={currentMobileSource}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
item={item}
|
||||
|
||||
@@ -371,15 +371,105 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
|
||||
test("移动报销卡片显示业务编号", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/mobile/reimbursements");
|
||||
const listRequest = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url());
|
||||
return request.method() === "GET" && url.pathname === "/api/reimbursements";
|
||||
});
|
||||
await page.goto("/mobile/reimbursements?scope=all");
|
||||
|
||||
const requestedUrl = new URL((await listRequest).url());
|
||||
expect(requestedUrl.searchParams.get("scope")).toBe("own");
|
||||
await expect(page.getByRole("heading", { name: "移动报销", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursements-scope-own")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-reimbursement-card")).toBeVisible();
|
||||
await expect(page.getByText(formNumber)).toBeVisible();
|
||||
await expect(page.getByText(reimbursementId)).toHaveCount(0);
|
||||
await expect(page.locator("table:visible")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("移动管理员默认查看本人并可切换全部报销", async ({ page }) => {
|
||||
const adminMe = {
|
||||
...me,
|
||||
id: "admin-r102",
|
||||
employeeNo: "A102",
|
||||
name: "测试管理员",
|
||||
roleCodes: ["admin"],
|
||||
};
|
||||
const otherReimbursementId = "44444444-4444-4444-8444-444444444444";
|
||||
const requestedScopes: Array<string | null> = [];
|
||||
|
||||
await page.route("**/api/me", async (route) => {
|
||||
await fulfillJson(route, adminMe);
|
||||
});
|
||||
await page.route((url) => url.pathname === "/api/reimbursements", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const scope = url.searchParams.get("scope");
|
||||
requestedScopes.push(scope);
|
||||
const ownItem = {
|
||||
...reimbursementListItem(),
|
||||
status: "reviewing",
|
||||
owner: { id: adminMe.id, employeeNo: adminMe.employeeNo, name: adminMe.name },
|
||||
submitter: { id: adminMe.id, employeeNo: adminMe.employeeNo, name: adminMe.name },
|
||||
};
|
||||
const otherItem = {
|
||||
...ownItem,
|
||||
id: otherReimbursementId,
|
||||
formNumber: "BX-20260731-0002",
|
||||
owner: { id: "employee-other-r102", employeeNo: "E103", name: "其他员工" },
|
||||
submitter: { id: "employee-other-r102", employeeNo: "E103", name: "其他员工" },
|
||||
};
|
||||
const items = scope === "all" ? [ownItem, otherItem] : [ownItem];
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: scope ?? "own", items });
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/mobile/reimbursements?status=reviewing");
|
||||
|
||||
await expect(page.getByTestId("mobile-reimbursements-scope-own")).toHaveAttribute("data-state", "active");
|
||||
await expect(page.getByText("其他员工")).toHaveCount(0);
|
||||
expect(requestedScopes.at(-1)).toBe("own");
|
||||
|
||||
const ownBounds = await page.getByTestId("mobile-reimbursements-scope-own").boundingBox();
|
||||
const allBounds = await page.getByTestId("mobile-reimbursements-scope-all").boundingBox();
|
||||
expect(ownBounds?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(allBounds?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
|
||||
const allResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.ok() && url.pathname === "/api/reimbursements" && url.searchParams.get("scope") === "all";
|
||||
});
|
||||
await page.getByTestId("mobile-reimbursements-scope-all").click();
|
||||
await allResponse;
|
||||
|
||||
await expect(page).toHaveURL(/scope=all/);
|
||||
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toHaveAttribute("data-state", "active");
|
||||
await expect(page.getByText("其他员工")).toBeVisible();
|
||||
expect(requestedScopes.at(-1)).toBe("all");
|
||||
await expect(page.getByTestId("mobile-reimbursement-delete")).toHaveCount(0);
|
||||
|
||||
await page.getByText("其他员工").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/mobile/reimbursements/${otherReimbursementId}$`));
|
||||
const routeState = await page.evaluate(() => window.history.state?.usr ?? null);
|
||||
expect(routeState?.from).toContain("status=reviewing");
|
||||
expect(routeState?.from).toContain("scope=all");
|
||||
|
||||
await page.goBack();
|
||||
await page.setViewportSize({ width: 667, height: 375 });
|
||||
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toBeVisible();
|
||||
const horizontalOverflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
);
|
||||
expect(horizontalOverflow).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
|
||||
在新工单中引用
屏蔽一个用户