diff --git a/e2e/vendors/vendor-operations.spec.ts b/e2e/vendors/vendor-operations.spec.ts index edfaf191..13c7b1eb 100644 --- a/e2e/vendors/vendor-operations.spec.ts +++ b/e2e/vendors/vendor-operations.spec.ts @@ -38,14 +38,16 @@ test.describe("Vendor operations", () => { await page.goto("/vendors/insights"); - await expect(page.getByRole("link", { name: "Vendors", exact: true })).toHaveAttribute( - "aria-current", - "page", - ); - await expect(page.getByRole("link", { name: "Sites", exact: true })).not.toHaveAttribute( - "aria-current", - "page", - ); + const vendorLinks = page.locator('a[href^="/vendors"]'); + await expect( + vendorLinks.filter({ has: page.getByText("Vendor Insights", { exact: true }) }), + ).toHaveAttribute("aria-current", "page"); + await expect( + vendorLinks.filter({ has: page.getByText("Vendors", { exact: true }) }), + ).not.toHaveAttribute("aria-current", "page"); + await expect( + vendorLinks.filter({ has: page.getByText("Site Preferences", { exact: true }) }), + ).not.toHaveAttribute("aria-current", "page"); }); test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => { @@ -252,7 +254,7 @@ test.describe("Vendor operations", () => { }); await page.goto("/vendors/preferences"); - await page.getByLabel("Site", { exact: true }).click(); + await page.getByLabel("Site").click(); await page.getByRole("option", { name: "St. Louis HQ" }).click(); await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible(); await page.getByRole("button", { name: "Move Metro HVAC up" }).click(); diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index a426d97a..e8f54bef 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -795,9 +795,9 @@ test.describe("Vendor directory prototype parity", () => { for (const destination of [ { path: "/workorders", label: "Work Orders" }, - { path: "/vendors", label: "Vendors" }, + { path: "/accounts", label: "Accounts" }, { path: "/locations", label: "Sites" }, - { path: "/account", label: "My Account" }, + { path: "/notifications", label: "Notifications" }, ]) { await page.goto(destination.path); await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute( diff --git a/src/app/(protected)/account/index.tsx b/src/app/(protected)/account/index.tsx deleted file mode 100644 index 47d7e04b..00000000 --- a/src/app/(protected)/account/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PlaceholderPage } from "@/components/common/placeholder-page"; - -export default function AccountPage() { - return ; -} diff --git a/src/app/(protected)/completion-docs/index.tsx b/src/app/(protected)/completion-docs/index.tsx deleted file mode 100644 index e5c9ea2f..00000000 --- a/src/app/(protected)/completion-docs/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PlaceholderPage } from "@/components/common/placeholder-page"; - -export default function CompletionDocsPage() { - return ; -} diff --git a/src/app/(protected)/services/index.tsx b/src/app/(protected)/services/index.tsx deleted file mode 100644 index e86bc94b..00000000 --- a/src/app/(protected)/services/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PlaceholderPage } from "@/components/common/placeholder-page"; - -export default function ServicesPage() { - return ; -} diff --git a/src/components/common/placeholder-page.tsx b/src/components/common/placeholder-page.tsx deleted file mode 100644 index 3d110268..00000000 --- a/src/components/common/placeholder-page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Box } from "@mui/material"; - -import { Text } from "@/components/ui/text"; - -type PlaceholderPageProps = { - title: string; -}; - -export function PlaceholderPage({ title }: PlaceholderPageProps) { - return ( - - - {title} - - - This page is under construction. - - - ); -} diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 99d92161..84aeb606 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -1,66 +1,120 @@ -import { useLocation } from "react-router"; +import { useState } from "react"; +import { Link, useLocation } from "react-router"; import { Box, Drawer } from "@mui/material"; import { BrandLockup, BrandMark } from "@/components/common/brand-lockup"; -import { SidebarNavItem } from "@/components/layout/sidebar-nav-item"; -import { Text } from "@/components/ui/text"; -import { - accountNavItem, - getActiveNavPath, - getSidebarWidth, - getVisibleNavGroups, -} from "@/config/menu"; -import { useAuthContext } from "@/providers/auth-context"; +import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu"; -type NavGroupHeadingProps = { - title: string; +function getNavItemAppearance(isActive: boolean, isHover: boolean) { + if (isActive) { + return { + itemColor: "var(--primary)", + iconColor: "var(--primary)", + itemBg: "var(--color-sidebar-item-active-bg)", + }; + } + + if (isHover) { + return { + itemColor: "var(--primary-hover)", + iconColor: "var(--primary-hover)", + itemBg: "var(--sidebar-accent)", + }; + } + + return { + itemColor: "var(--foreground)", + iconColor: "var(--muted-foreground)", + itemBg: "transparent", + }; +} + +type NavItemProps = NavItemDef & { collapsed: boolean; - showDivider: boolean; + active: boolean; + onNavigate?: () => void; }; -function NavGroupHeading({ title, collapsed, showDivider }: NavGroupHeadingProps) { - if (collapsed) { - if (!showDivider) { - return null; - } - - return ( - - ); - } - - if (!title) { - return ; - } +function NavItem({ label, icon: Icon, path, collapsed, active, badge, onNavigate }: NavItemProps) { + const [hovered, setHovered] = useState(false); + const isHover = hovered && !active; + const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover); return ( - - {title} - + setHovered(true)} + onMouseLeave={() => setHovered(false)} + sx={{ + position: "relative", + display: "flex", + alignItems: "center", + width: "100%", + height: 40, + gap: "12px", + px: collapsed ? 0 : "12px", + justifyContent: collapsed ? "center" : "flex-start", + borderRadius: "6px", + cursor: "pointer", + fontFamily: "var(--font-sans)", + fontSize: 14, + fontWeight: active ? 600 : 500, + color: itemColor, + backgroundColor: itemBg, + boxShadow: active ? "var(--color-sidebar-item-active-shadow)" : "none", + transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)", + mb: "4px", + textAlign: "left", + }} + > + {active && ( + + )} + + {!collapsed && ( + <> + {label} + {badge != null && ( + + {badge} + + )} + + )} + + ); } @@ -74,10 +128,7 @@ type AppSidebarProps = { export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: AppSidebarProps) { const width = getSidebarWidth(collapsed); const { pathname } = useLocation(); - const { user } = useAuthContext(); const activePath = getActiveNavPath(pathname); - const visibleGroups = getVisibleNavGroups(user?.userRoles); - const onNavigate = mobile ? onClose : undefined; return ( - {visibleGroups.map((group, groupIndex) => ( + {navGroups.map((group, groupIndex) => ( - 0} - /> + {collapsed ? ( + groupIndex > 0 && ( + + ) + ) : group.title ? ( + + {group.title} + + ) : ( + + )} {group.items.map(({ label, icon, path, badge }) => ( - ))} @@ -147,15 +220,6 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: ))} - - - - ); diff --git a/src/components/layout/sidebar-nav-item.tsx b/src/components/layout/sidebar-nav-item.tsx deleted file mode 100644 index eb1f98b0..00000000 --- a/src/components/layout/sidebar-nav-item.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useState } from "react"; -import { Link } from "react-router"; -import { Box } from "@mui/material"; - -import { getVisibleBadge, type NavItemDef } from "@/config/menu"; - -function getNavItemAppearance(isActive: boolean, isHover: boolean) { - if (isActive) { - return { - itemColor: "var(--primary)", - iconColor: "var(--primary)", - itemBg: "var(--color-sidebar-item-active-bg)", - }; - } - - if (isHover) { - return { - itemColor: "var(--primary-hover)", - iconColor: "var(--primary-hover)", - itemBg: "var(--sidebar-accent)", - }; - } - - return { - itemColor: "var(--foreground)", - iconColor: "var(--muted-foreground)", - itemBg: "transparent", - }; -} - -type SidebarNavItemProps = NavItemDef & { - collapsed: boolean; - active: boolean; - onNavigate?: () => void; -}; - -export function SidebarNavItem({ - label, - icon: Icon, - path, - collapsed, - active, - badge, - onNavigate, -}: SidebarNavItemProps) { - const [hovered, setHovered] = useState(false); - const isHover = hovered && !active; - const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover); - const visibleBadge = getVisibleBadge(badge); - - return ( - - setHovered(true)} - onMouseLeave={() => setHovered(false)} - sx={{ - position: "relative", - display: "flex", - alignItems: "center", - width: "100%", - height: 40, - gap: "12px", - px: collapsed ? 0 : "12px", - justifyContent: collapsed ? "center" : "flex-start", - borderRadius: "6px", - cursor: "pointer", - fontFamily: "var(--font-sans)", - fontSize: 14, - fontWeight: active ? 600 : 500, - color: itemColor, - backgroundColor: itemBg, - boxShadow: active ? "var(--color-sidebar-item-active-shadow)" : "none", - transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)", - mb: "4px", - textAlign: "left", - }} - > - {active && ( - - )} - - {Boolean(!collapsed) && ( - <> - {label} - {visibleBadge != null && ( - - {visibleBadge} - - )} - - )} - - - ); -} diff --git a/src/config/menu.ts b/src/config/menu.ts index 01303e59..3974c0dc 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -1,18 +1,17 @@ import type { LucideIcon } from "lucide-react"; import { + // BarChart2, + BriefcaseBusiness, Building2, ClipboardList, - DollarSign, FileText, LayoutDashboard, MapPin, - Settings, - UserRound, - Wrench, + Bell, + BarChart3, + Star, } from "lucide-react"; -import { isAdminUser } from "@/lib/auth/user-utils"; - export type NavItemDef = { label: string; icon: LucideIcon; @@ -23,66 +22,52 @@ export type NavItemDef = { export type NavGroup = { title: string; items: NavItemDef[]; - visible?: (userRoles: string | null | undefined) => boolean; }; export const SIDEBAR_WIDTH = 244; export const SIDEBAR_COLLAPSED_WIDTH = 76; -export const accountNavItem: NavItemDef = { - label: "My Account", - icon: Settings, - path: "/account", -}; - export function getSidebarWidth(collapsed: boolean): number { return collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH; } export const navGroups: NavGroup[] = [ { - title: "OPERATIONS", + title: "", items: [ - { label: "Work Orders", icon: ClipboardList, path: "/workorders" }, + { label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 }, { label: "Dashboard", icon: LayoutDashboard, path: "/" }, + { label: "Notifications", icon: Bell, path: "/notifications" }, + // { label: "Reports", icon: BarChart2, path: "/reports" }, ], }, { - title: "DATA MANAGEMENT", + title: "VENDORS", items: [ { label: "Vendors", icon: Building2, path: "/vendors" }, - { label: "Sites", icon: MapPin, path: "/locations" }, - { label: "Services", icon: Wrench, path: "/services" }, - { label: "Completion Docs", icon: FileText, path: "/completion-docs" }, + { label: "Vendor POs", icon: FileText, path: "/vendor-pos" }, + { label: "Vendor Insights", icon: BarChart3, path: "/vendors/insights" }, + { label: "Site Preferences", icon: Star, path: "/vendors/preferences" }, ], }, { - title: "TEAM", - visible: isAdminUser, - items: [{ label: "Team Members", icon: UserRound, path: "/employees" }], - }, - { - title: "APPROVALS", - visible: isAdminUser, - items: [{ label: "Uplift Approvals", icon: DollarSign, path: "/uplifts" }], + title: "CUSTOMERS & SITES", + items: [ + { label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" }, + { label: "Sites", icon: MapPin, path: "/locations" }, + ], }, + // { + // title: "INTERNAL", + // items: [{ label: "Documents", icon: FileText, path: "/documents" }], + // }, ]; -export function getVisibleNavGroups(userRoles: string | null | undefined): NavGroup[] { - return navGroups.filter((group) => group.visible == null || group.visible(userRoles)); -} - -export function getVisibleBadge(badge?: number): number | undefined { - return badge != null && badge > 0 ? badge : undefined; -} - -function collectNavPaths(): string[] { - return [...navGroups.flatMap((group) => group.items), accountNavItem].map((item) => item.path); -} - export function getActiveNavPath(pathname: string): string | undefined { - return collectNavPaths() + return navGroups + .flatMap((group) => group.items) + .map((item) => item.path) .filter((path) => path === "/" ? pathname === path : pathname === path || pathname.startsWith(`${path}/`), ) diff --git a/src/styles/typography.css b/src/styles/typography.css index ed591c42..34d61f9e 100644 --- a/src/styles/typography.css +++ b/src/styles/typography.css @@ -73,7 +73,7 @@ h6 { } .text-nav-group { - font-family: var(--font-display); + font-family: var(--font-mono); font-size: 10px; font-weight: 600; line-height: normal; diff --git a/src/test/components/common/placeholder-page.test.tsx b/src/test/components/common/placeholder-page.test.tsx deleted file mode 100644 index 0b4f7056..00000000 --- a/src/test/components/common/placeholder-page.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { PlaceholderPage } from "@/components/common/placeholder-page"; -import { renderWithProviders } from "@/test/test-utils"; - -describe("PlaceholderPage", () => { - it("renders the AAP under-construction copy", () => { - renderWithProviders(, { withAuth: false }); - - expect(screen.getByRole("heading", { name: "Services" })).toBeInTheDocument(); - expect(screen.getByText("This page is under construction.")).toBeInTheDocument(); - }); -}); diff --git a/src/test/components/layout/app-sidebar.test.tsx b/src/test/components/layout/app-sidebar.test.tsx deleted file mode 100644 index 390256cf..00000000 --- a/src/test/components/layout/app-sidebar.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { screen } from "@testing-library/react"; -import type { ReactElement } from "react"; -import { describe, expect, it, vi } from "vitest"; - -import { AppSidebar } from "@/components/layout/app-sidebar"; -import type { AuthUser } from "@/domain/auth/types/auth-user"; -import { AuthContext, type AuthContextValue } from "@/providers/auth-context"; -import { renderWithProviders } from "@/test/test-utils"; - -function createUser(userRoles: string): AuthUser { - return { - token: "test-jwt-token", - expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), - email: "user@seahavenind.com", - userRoles, - phoneNumber: "5551234567", - fullname: "Test User", - id: 1, - }; -} - -function createAuthValue(userRoles: string): AuthContextValue { - return { - user: createUser(userRoles), - isAuthenticated: true, - isLoading: false, - error: null, - login: vi.fn(), - logout: vi.fn(), - changePassword: vi.fn(), - isLoggingIn: false, - isLoggingOut: false, - isChangingPassword: false, - loginError: null, - }; -} - -function renderSidebar(ui: ReactElement, userRoles: string, route = "/workorders") { - return renderWithProviders( - {ui}, - { - withAuth: false, - route, - }, - ); -} - -describe("AppSidebar", () => { - it("renders the AAP tree plus My Account for Admin", () => { - renderSidebar(, "Admin"); - - expect(screen.getByText("OPERATIONS")).toBeInTheDocument(); - expect(screen.getByText("DATA MANAGEMENT")).toBeInTheDocument(); - expect(screen.getByText("TEAM")).toBeInTheDocument(); - expect(screen.getByText("APPROVALS")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Work Orders" })).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Team Members" })).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Uplift Approvals" })).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "My Account" })).toBeInTheDocument(); - expect(screen.queryByRole("link", { name: "Notifications" })).not.toBeInTheDocument(); - expect(screen.queryByText("11")).not.toBeInTheDocument(); - }); - - it("hides TEAM and APPROVALS for non-admin and keeps My Account", () => { - renderSidebar(, "Dispatcher"); - - expect(screen.getByText("OPERATIONS")).toBeInTheDocument(); - expect(screen.queryByText("TEAM")).not.toBeInTheDocument(); - expect(screen.queryByText("APPROVALS")).not.toBeInTheDocument(); - expect(screen.queryByRole("link", { name: "Team Members" })).not.toBeInTheDocument(); - expect(screen.queryByRole("link", { name: "Uplift Approvals" })).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "My Account" })).toBeInTheDocument(); - }); - - it("hides group labels and item text when collapsed", () => { - renderSidebar(, "Admin"); - - expect(screen.queryByText("OPERATIONS")).not.toBeInTheDocument(); - expect(screen.queryByText("Work Orders")).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Work Orders" })).toHaveAttribute( - "title", - "Work Orders", - ); - expect(screen.getByRole("link", { name: "My Account" })).toHaveAttribute("title", "My Account"); - }); - - it("marks the active leaf from the current route", () => { - renderSidebar(, "Admin", "/uplifts"); - - expect(screen.getByRole("link", { name: "Uplift Approvals" })).toHaveAttribute( - "aria-current", - "page", - ); - expect(screen.getByRole("link", { name: "Work Orders" })).not.toHaveAttribute("aria-current"); - }); -}); diff --git a/src/test/components/layout/sidebar-nav-item.test.tsx b/src/test/components/layout/sidebar-nav-item.test.tsx deleted file mode 100644 index cefae72d..00000000 --- a/src/test/components/layout/sidebar-nav-item.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { ClipboardList } from "lucide-react"; -import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { SidebarNavItem } from "@/components/layout/sidebar-nav-item"; -import { renderWithProviders } from "@/test/test-utils"; - -describe("SidebarNavItem", () => { - it("omits a zero badge", () => { - renderWithProviders( - , - { withAuth: false, route: "/workorders" }, - ); - - expect(screen.getByRole("link", { name: "Work Orders" })).toBeInTheDocument(); - expect(screen.queryByText("0")).not.toBeInTheDocument(); - }); - - it("shows a positive badge", () => { - renderWithProviders( - , - { withAuth: false, route: "/uplifts" }, - ); - - expect(screen.getByText("2")).toBeInTheDocument(); - }); -}); diff --git a/src/test/config/menu.test.ts b/src/test/config/menu.test.ts index a5afd99d..ac75b38c 100644 --- a/src/test/config/menu.test.ts +++ b/src/test/config/menu.test.ts @@ -1,79 +1,20 @@ -import { getActiveNavPath, getVisibleBadge, getVisibleNavGroups, navGroups } from "@/config/menu"; +import { getActiveNavPath } from "@/config/menu"; import { describe, expect, it } from "vitest"; -describe("navGroups", () => { - it("exposes the AAP flat tree in order", () => { - expect(navGroups.map((group) => group.title)).toEqual([ - "OPERATIONS", - "DATA MANAGEMENT", - "TEAM", - "APPROVALS", - ]); - expect(navGroups.flatMap((group) => group.items.map((item) => item.label))).toEqual([ - "Work Orders", - "Dashboard", - "Vendors", - "Sites", - "Services", - "Completion Docs", - "Team Members", - "Uplift Approvals", - ]); - }); - - it("does not hardcode a Work Orders badge", () => { - const workOrders = navGroups - .flatMap((group) => group.items) - .find((item) => item.label === "Work Orders"); - - expect(workOrders?.badge).toBeUndefined(); - }); -}); - -describe("getVisibleNavGroups", () => { - it("shows TEAM and APPROVALS only for Admin", () => { - expect(getVisibleNavGroups("Admin").map((group) => group.title)).toEqual([ - "OPERATIONS", - "DATA MANAGEMENT", - "TEAM", - "APPROVALS", - ]); - expect(getVisibleNavGroups("Dispatcher").map((group) => group.title)).toEqual([ - "OPERATIONS", - "DATA MANAGEMENT", - ]); - }); -}); - -describe("getVisibleBadge", () => { - it("hides missing or zero counts", () => { - expect(getVisibleBadge(undefined)).toBeUndefined(); - expect(getVisibleBadge(0)).toBeUndefined(); - expect(getVisibleBadge(2)).toBe(2); - }); -}); - describe("getActiveNavPath", () => { it.each([ ["/", "/"], - ["/workorders", "/workorders"], - ["/workorders/42", "/workorders"], ["/vendors", "/vendors"], ["/vendors/42", "/vendors"], - ["/vendors/insights", "/vendors"], - ["/locations", "/locations"], - ["/services", "/services"], - ["/completion-docs", "/completion-docs"], - ["/employees", "/employees"], - ["/employees/12", "/employees"], - ["/uplifts", "/uplifts"], - ["/account", "/account"], + ["/vendors/42/edit", "/vendors"], + ["/vendors/insights", "/vendors/insights"], + ["/vendors/preferences", "/vendors/preferences"], + ["/vendor-pos/42", "/vendor-pos"], ])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => { expect(getActiveNavPath(pathname)).toBe(expectedPath); }); it("does not match partial route segments", () => { expect(getActiveNavPath("/vendor-posts")).toBeUndefined(); - expect(getActiveNavPath("/accounts")).toBeUndefined(); }); });