diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png index a078ccf6..8a314581 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-admin.png differ diff --git a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png index 0db529fe..8f3a6331 100644 Binary files a/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png and b/e2e/__screenshots__/dashboard/dashboard.visual.spec.ts/dashboard-dispatcher.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index f4feaee6..bcd00f31 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index 0cd62c6d..08602f38 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index 80fb28ce..a22451fa 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png index 1b239e83..ad205418 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-empty.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png index 310f2ce5..ee67ef29 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-error.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png index 9f45bb9b..0e97260a 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-filter.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png index 6742d5c8..da22ab30 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-inactive.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png index bc920e8d..59dc78f3 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-list.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png index fb262589..6fab2b85 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index fe49083c..cd50b14e 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png index dba22877..4e0ca158 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png index c921fc40..2db0cea4 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index c1d2bd63..d679e81a 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index 73995d8f..7080f709 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index c5f9d6e9..9707bda7 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 121879f8..a42f3f50 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 84aeb606..e897bb89 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -3,7 +3,8 @@ import { Link, useLocation } from "react-router"; import { Box, Drawer } from "@mui/material"; import { BrandLockup, BrandMark } from "@/components/common/brand-lockup"; -import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu"; +import { useNavGroups } from "@/components/layout/use-nav-groups"; +import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu"; function getNavItemAppearance(isActive: boolean, isHover: boolean) { if (isActive) { @@ -129,6 +130,7 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: const width = getSidebarWidth(collapsed); const { pathname } = useLocation(); const activePath = getActiveNavPath(pathname); + const navGroups = useNavGroups(); return ( + query.data === true && !query.isError; + const canReviewUplifts = allows(tier1) || allows(tier2); + + return getNavGroups({ canReviewUplifts }); +} diff --git a/src/config/menu.ts b/src/config/menu.ts index c5b6fce4..2c07d62a 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -1,6 +1,7 @@ import type { LucideIcon } from "lucide-react"; import { // BarChart2, + BadgeDollarSign, BriefcaseBusiness, Building2, ClipboardList, @@ -11,6 +12,7 @@ import { BarChart3, Star, Users, + Wrench, } from "lucide-react"; export type NavItemDef = { @@ -59,6 +61,10 @@ export const navGroups: NavGroup[] = [ { label: "Sites", icon: MapPin, path: "/locations" }, ], }, + { + title: "DATA MANAGEMENT", + items: [{ label: "Services", icon: Wrench, path: "/settings/services" }], + }, { title: "TEAM", items: [{ label: "Team Members", icon: Users, path: "/team-members" }], @@ -69,8 +75,21 @@ export const navGroups: NavGroup[] = [ // }, ]; +// Approvals navigation is gated on the same server-derived authority the +// uplift approve/reject API enforces (UpliftService.CanApprove tier roles, +// exposed via GET uplifts/can-approve), so the sidebar never shows the queue +// to a user the backend would refuse. +const approvalsNavGroup: NavGroup = { + title: "APPROVALS", + items: [{ label: "Uplift Approvals", icon: BadgeDollarSign, path: "/uplifts" }], +}; + +export function getNavGroups({ canReviewUplifts }: { canReviewUplifts: boolean }): NavGroup[] { + return canReviewUplifts ? [...navGroups, approvalsNavGroup] : navGroups; +} + export function getActiveNavPath(pathname: string): string | undefined { - return navGroups + return [...navGroups, approvalsNavGroup] .flatMap((group) => group.items) .map((item) => item.path) .filter((path) => diff --git a/src/test/components/layout/app-sidebar-can-approve.test.tsx b/src/test/components/layout/app-sidebar-can-approve.test.tsx new file mode 100644 index 00000000..9db93295 --- /dev/null +++ b/src/test/components/layout/app-sidebar-can-approve.test.tsx @@ -0,0 +1,58 @@ +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AppSidebar } from "@/components/layout/app-sidebar"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { createTestQueryClient, renderWithProviders } from "@/test/test-utils"; + +const canApproveApiMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/domain/uplifts/api/uplifts-api", () => ({ + upliftsApi: { canApprove: canApproveApiMock }, +})); + +describe("AppSidebar Uplift Approvals gating through the can-approve query", () => { + beforeEach(() => { + canApproveApiMock.mockReset(); + }); + + it("shows the entry when the API allows tier 2 and fails for tier 1", async () => { + canApproveApiMock.mockImplementation(async (tier: number) => { + if (tier === 1) throw new Error("503"); + return true; + }); + + renderWithProviders(, { route: "/", withAuth: false }); + + expect(await screen.findByRole("link", { name: "Uplift Approvals" })).toHaveAttribute( + "href", + "/uplifts", + ); + expect(canApproveApiMock).toHaveBeenCalledWith(1); + expect(canApproveApiMock).toHaveBeenCalledWith(2); + }); + + it("keeps the entry hidden once the API has denied both tiers", async () => { + canApproveApiMock.mockResolvedValue(false); + const queryClient = createTestQueryClient(); + + renderWithProviders(, { + route: "/", + withAuth: false, + queryClient, + }); + + await waitFor(() => { + for (const tier of [1, 2]) { + expect(queryClient.getQueryState(queryKeys.uplifts.canApprove(tier))?.status).toBe( + "success", + ); + } + }); + expect(screen.queryByRole("link", { name: "Uplift Approvals" })).toBeNull(); + expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute( + "href", + "/settings/services", + ); + }); +}); diff --git a/src/test/components/layout/app-sidebar-nav.test.tsx b/src/test/components/layout/app-sidebar-nav.test.tsx new file mode 100644 index 00000000..07e58155 --- /dev/null +++ b/src/test/components/layout/app-sidebar-nav.test.tsx @@ -0,0 +1,115 @@ +import { screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AppSidebar } from "@/components/layout/app-sidebar"; +import { renderWithProviders } from "@/test/test-utils"; + +const canApproveMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({ + useUpliftsCanApprove: canApproveMock, +})); + +function renderSidebar(route: string) { + return renderWithProviders(, { + route, + withAuth: false, + }); +} + +function sidebarLinks(): Record { + const nav = document.getElementById("primary-navigation"); + const links = Array.from(within(nav as HTMLElement).getAllByRole("link")); + return Object.fromEntries( + links.map((link) => [link.textContent ?? "", link.getAttribute("href") ?? ""]), + ); +} + +describe("AppSidebar navigation entries", () => { + it("shows Uplift Approvals for a user the backend says can approve uplifts", () => { + canApproveMock.mockReturnValue({ data: true }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBe("/uplifts"); + }); + + it("hides Uplift Approvals when the backend says the user cannot approve uplifts", () => { + canApproveMock.mockReturnValue({ data: false }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBeUndefined(); + }); + + it("hides Uplift Approvals while the backend answer is still loading", () => { + canApproveMock.mockReturnValue({ data: undefined }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBeUndefined(); + }); + + it.each([ + [1, "tier 1"], + [2, "tier 2"], + ])("shows Uplift Approvals when only %s approval is allowed (%s)", (allowedTier) => { + canApproveMock.mockImplementation((tier: number) => ({ data: tier === allowedTier })); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBe("/uplifts"); + expect(canApproveMock).toHaveBeenCalledWith(1); + expect(canApproveMock).toHaveBeenCalledWith(2); + }); + + it("hides Uplift Approvals when the can-approve request fails", () => { + canApproveMock.mockReturnValue({ data: undefined, isError: true, error: new Error("403") }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBeUndefined(); + }); + + it("hides Uplift Approvals when a refetch fails after an earlier approval", () => { + canApproveMock.mockReturnValue({ data: true, isError: true, error: new Error("403") }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Uplift Approvals"]).toBeUndefined(); + }); + + it("offers Services to every user", () => { + canApproveMock.mockReturnValue({ data: false }); + + renderSidebar("/"); + const links = sidebarLinks(); + + expect(links["Services"]).toBe("/settings/services"); + }); + + it("highlights the Uplift Approvals entry on /uplifts", () => { + canApproveMock.mockReturnValue({ data: true }); + + renderSidebar("/uplifts"); + + expect(screen.getByRole("link", { name: "Uplift Approvals" })).toHaveAttribute( + "aria-current", + "page", + ); + }); + + it("highlights the Services entry on /settings/services", () => { + canApproveMock.mockReturnValue({ data: false }); + + renderSidebar("/settings/services"); + + expect(screen.getByRole("link", { name: "Services" })).toHaveAttribute("aria-current", "page"); + }); +}); diff --git a/src/test/config/menu.test.ts b/src/test/config/menu.test.ts index ac75b38c..61184661 100644 --- a/src/test/config/menu.test.ts +++ b/src/test/config/menu.test.ts @@ -1,4 +1,4 @@ -import { getActiveNavPath } from "@/config/menu"; +import { getActiveNavPath, getNavGroups, navGroups } from "@/config/menu"; import { describe, expect, it } from "vitest"; describe("getActiveNavPath", () => { @@ -10,6 +10,9 @@ describe("getActiveNavPath", () => { ["/vendors/insights", "/vendors/insights"], ["/vendors/preferences", "/vendors/preferences"], ["/vendor-pos/42", "/vendor-pos"], + ["/settings/services", "/settings/services"], + ["/uplifts", "/uplifts"], + ["/uplifts/12", "/uplifts"], ])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => { expect(getActiveNavPath(pathname)).toBe(expectedPath); }); @@ -17,4 +20,39 @@ describe("getActiveNavPath", () => { it("does not match partial route segments", () => { expect(getActiveNavPath("/vendor-posts")).toBeUndefined(); }); + + it("does not highlight the Services entry from other settings tabs", () => { + expect(getActiveNavPath("/settings/dropdowns")).toBeUndefined(); + }); +}); + +describe("navGroups", () => { + it("always offers Services in a DATA MANAGEMENT group", () => { + const dataManagement = navGroups.find((group) => group.title === "DATA MANAGEMENT"); + expect(dataManagement?.items.map((item) => item.path)).toContain("/settings/services"); + }); + + it("does not include the approvals group by default", () => { + expect(navGroups.some((group) => group.title === "APPROVALS")).toBe(false); + }); +}); + +describe("getNavGroups", () => { + it("adds the APPROVALS group with Uplift Approvals for uplift reviewers", () => { + const groups = getNavGroups({ canReviewUplifts: true }); + const approvals = groups.find((group) => group.title === "APPROVALS"); + expect(approvals?.items.map((item) => item.path)).toEqual(["/uplifts"]); + expect(approvals?.items[0]?.label).toBe("Uplift Approvals"); + }); + + it("omits the APPROVALS group for users who cannot review uplifts", () => { + const groups = getNavGroups({ canReviewUplifts: false }); + expect(groups.some((group) => group.title === "APPROVALS")).toBe(false); + }); + + it("keeps the approvals group after TEAM", () => { + const groups = getNavGroups({ canReviewUplifts: true }); + const titles = groups.map((group) => group.title); + expect(titles.indexOf("TEAM")).toBeLessThan(titles.indexOf("APPROVALS")); + }); });