diff --git a/src/config/menu.ts b/src/config/menu.ts
index 3974c0dc..01303e59 100644
--- a/src/config/menu.ts
+++ b/src/config/menu.ts
@@ -1,17 +1,18 @@
import type { LucideIcon } from "lucide-react";
import {
- // BarChart2,
- BriefcaseBusiness,
Building2,
ClipboardList,
+ DollarSign,
FileText,
LayoutDashboard,
MapPin,
- Bell,
- BarChart3,
- Star,
+ Settings,
+ UserRound,
+ Wrench,
} from "lucide-react";
+import { isAdminUser } from "@/lib/auth/user-utils";
+
export type NavItemDef = {
label: string;
icon: LucideIcon;
@@ -22,52 +23,66 @@ 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: "",
+ title: "OPERATIONS",
items: [
- { label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 },
+ { label: "Work Orders", icon: ClipboardList, path: "/workorders" },
{ label: "Dashboard", icon: LayoutDashboard, path: "/" },
- { label: "Notifications", icon: Bell, path: "/notifications" },
- // { label: "Reports", icon: BarChart2, path: "/reports" },
],
},
{
- title: "VENDORS",
+ title: "DATA MANAGEMENT",
items: [
{ label: "Vendors", icon: Building2, path: "/vendors" },
- { label: "Vendor POs", icon: FileText, path: "/vendor-pos" },
- { label: "Vendor Insights", icon: BarChart3, path: "/vendors/insights" },
- { label: "Site Preferences", icon: Star, path: "/vendors/preferences" },
+ { label: "Sites", icon: MapPin, path: "/locations" },
+ { label: "Services", icon: Wrench, path: "/services" },
+ { label: "Completion Docs", icon: FileText, path: "/completion-docs" },
],
},
{
- title: "CUSTOMERS & SITES",
- items: [
- { label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" },
- { label: "Sites", icon: MapPin, path: "/locations" },
- ],
+ 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: "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 navGroups
- .flatMap((group) => group.items)
- .map((item) => item.path)
+ return collectNavPaths()
.filter((path) =>
path === "/" ? pathname === path : pathname === path || pathname.startsWith(`${path}/`),
)
diff --git a/src/styles/typography.css b/src/styles/typography.css
index 34d61f9e..ed591c42 100644
--- a/src/styles/typography.css
+++ b/src/styles/typography.css
@@ -73,7 +73,7 @@ h6 {
}
.text-nav-group {
- font-family: var(--font-mono);
+ font-family: var(--font-display);
font-size: 10px;
font-weight: 600;
line-height: normal;
diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
index 5b1e50c8..2bcf1cf6 100644
--- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx
@@ -152,6 +152,8 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(within(sheet).getByText("HVAC")).toBeInTheDocument();
expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument();
+ fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
+ fireEvent.click(within(sheet).getByRole("checkbox", { name: /Aveta required only/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /apply filters/i }));
expect(applyAdvancedFilters).toHaveBeenCalled();
expect(setAdvOpen).toHaveBeenCalledWith(false);
@@ -341,6 +343,45 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(screen.getByRole("button", { name: /work order types, all types/i })).toBeEnabled();
});
+ it("places Aveta Compliance immediately above Uplift", () => {
+ const view = renderWithProviders(
, {
+ route: "/workorders",
+ withAuth: false,
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
+ advOpen = true;
+ view.rerender(
);
+
+ const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
+ const aveta = within(sheet).getByRole("button", { name: /Aveta Compliance/i });
+ const uplift = within(sheet).getByRole("button", { name: /^Uplift/i });
+ expect(aveta.closest(".border-b")?.nextElementSibling).toBe(uplift.closest(".border-b"));
+ });
+
+ it("keeps Clear all and Apply filters disabled until a filter is selected", () => {
+ const view = renderWithProviders(
, {
+ route: "/workorders",
+ withAuth: false,
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
+ advOpen = true;
+ view.rerender(
);
+
+ const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
+ const clearAll = within(sheet).getByRole("button", { name: /clear all/i });
+ const apply = within(sheet).getByRole("button", { name: /apply filters/i });
+ expect(clearAll).toBeDisabled();
+ expect(apply).toBeDisabled();
+
+ fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
+ fireEvent.click(within(sheet).getByRole("checkbox", { name: /Aveta required only/i }));
+
+ expect(clearAll).toBeEnabled();
+ expect(apply).toBeEnabled();
+ });
+
it("applies Aveta required only through Advanced Filters", () => {
const view = renderWithProviders(
, {
route: "/workorders",
diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
index e273324c..2398db28 100644
--- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
@@ -211,7 +211,9 @@ describe("WorkOrdersListHeader slice affordances", () => {
);
expect(screen.getByRole("button", { name: /new wo/i })).toBeInTheDocument();
- expect(screen.getByLabelText("Advanced Filters")).toBeInTheDocument();
+ const filters = screen.getByRole("button", { name: "Advanced Filters" });
+ expect(filters).toBeInTheDocument();
+ expect(filters).toHaveTextContent("Filters");
});
});
diff --git a/src/test/components/common/placeholder-page.test.tsx b/src/test/components/common/placeholder-page.test.tsx
new file mode 100644
index 00000000..0b4f7056
--- /dev/null
+++ b/src/test/components/common/placeholder-page.test.tsx
@@ -0,0 +1,14 @@
+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
new file mode 100644
index 00000000..390256cf
--- /dev/null
+++ b/src/test/components/layout/app-sidebar.test.tsx
@@ -0,0 +1,96 @@
+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
new file mode 100644
index 00000000..cefae72d
--- /dev/null
+++ b/src/test/components/layout/sidebar-nav-item.test.tsx
@@ -0,0 +1,41 @@
+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/components/ui/collapsible-section.test.tsx b/src/test/components/ui/collapsible-section.test.tsx
new file mode 100644
index 00000000..0d1d1994
--- /dev/null
+++ b/src/test/components/ui/collapsible-section.test.tsx
@@ -0,0 +1,16 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { CollapsibleSection } from "@/components/ui/collapsible-section";
+import { renderWithProviders } from "@/test/test-utils";
+
+describe("CollapsibleSection", () => {
+ it("renders the section title at the 14px body size token", () => {
+ renderWithProviders(
child, {
+ withAuth: false,
+ });
+
+ const title = screen.getByRole("button", { name: /Aveta Compliance/i }).querySelector("span");
+ expect(title).toHaveClass("text-[length:var(--text-base)]");
+ });
+});
diff --git a/src/test/config/menu.test.ts b/src/test/config/menu.test.ts
index ac75b38c..a5afd99d 100644
--- a/src/test/config/menu.test.ts
+++ b/src/test/config/menu.test.ts
@@ -1,20 +1,79 @@
-import { getActiveNavPath } from "@/config/menu";
+import { getActiveNavPath, getVisibleBadge, getVisibleNavGroups, navGroups } 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/42/edit", "/vendors"],
- ["/vendors/insights", "/vendors/insights"],
- ["/vendors/preferences", "/vendors/preferences"],
- ["/vendor-pos/42", "/vendor-pos"],
+ ["/vendors/insights", "/vendors"],
+ ["/locations", "/locations"],
+ ["/services", "/services"],
+ ["/completion-docs", "/completion-docs"],
+ ["/employees", "/employees"],
+ ["/employees/12", "/employees"],
+ ["/uplifts", "/uplifts"],
+ ["/account", "/account"],
])("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();
});
});