mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): keep sidebar scope unchanged
This commit is contained in:
parent
000aaae92d
commit
c4dd664e3f
14 changed files with 181 additions and 503 deletions
20
e2e/vendors/vendor-operations.spec.ts
vendored
20
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -38,14 +38,16 @@ test.describe("Vendor operations", () => {
|
||||||
|
|
||||||
await page.goto("/vendors/insights");
|
await page.goto("/vendors/insights");
|
||||||
|
|
||||||
await expect(page.getByRole("link", { name: "Vendors", exact: true })).toHaveAttribute(
|
const vendorLinks = page.locator('a[href^="/vendors"]');
|
||||||
"aria-current",
|
await expect(
|
||||||
"page",
|
vendorLinks.filter({ has: page.getByText("Vendor Insights", { exact: true }) }),
|
||||||
);
|
).toHaveAttribute("aria-current", "page");
|
||||||
await expect(page.getByRole("link", { name: "Sites", exact: true })).not.toHaveAttribute(
|
await expect(
|
||||||
"aria-current",
|
vendorLinks.filter({ has: page.getByText("Vendors", { exact: true }) }),
|
||||||
"page",
|
).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 }) => {
|
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.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 page.getByRole("option", { name: "St. Louis HQ" }).click();
|
||||||
await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible();
|
await expect(page.getByText(/1\. Gateway Plumbing/)).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Move Metro HVAC up" }).click();
|
await page.getByRole("button", { name: "Move Metro HVAC up" }).click();
|
||||||
|
|
|
||||||
4
e2e/vendors/vendors.spec.ts
vendored
4
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -795,9 +795,9 @@ test.describe("Vendor directory prototype parity", () => {
|
||||||
|
|
||||||
for (const destination of [
|
for (const destination of [
|
||||||
{ path: "/workorders", label: "Work Orders" },
|
{ path: "/workorders", label: "Work Orders" },
|
||||||
{ path: "/vendors", label: "Vendors" },
|
{ path: "/accounts", label: "Accounts" },
|
||||||
{ path: "/locations", label: "Sites" },
|
{ path: "/locations", label: "Sites" },
|
||||||
{ path: "/account", label: "My Account" },
|
{ path: "/notifications", label: "Notifications" },
|
||||||
]) {
|
]) {
|
||||||
await page.goto(destination.path);
|
await page.goto(destination.path);
|
||||||
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
|
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { PlaceholderPage } from "@/components/common/placeholder-page";
|
|
||||||
|
|
||||||
export default function AccountPage() {
|
|
||||||
return <PlaceholderPage title="My Account" />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { PlaceholderPage } from "@/components/common/placeholder-page";
|
|
||||||
|
|
||||||
export default function CompletionDocsPage() {
|
|
||||||
return <PlaceholderPage title="Completion Docs" />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { PlaceholderPage } from "@/components/common/placeholder-page";
|
|
||||||
|
|
||||||
export default function ServicesPage() {
|
|
||||||
return <PlaceholderPage title="Services" />;
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<Box className="p-8">
|
|
||||||
<Text as="h1" variant="title" sx={{ mb: 0.25, fontSize: 18, fontWeight: 600 }}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Text variant="description" tone="muted" sx={{ fontSize: 13 }}>
|
|
||||||
This page is under construction.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 { Box, Drawer } from "@mui/material";
|
||||||
|
|
||||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||||
import { SidebarNavItem } from "@/components/layout/sidebar-nav-item";
|
import { getActiveNavPath, getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import {
|
|
||||||
accountNavItem,
|
|
||||||
getActiveNavPath,
|
|
||||||
getSidebarWidth,
|
|
||||||
getVisibleNavGroups,
|
|
||||||
} from "@/config/menu";
|
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
|
||||||
|
|
||||||
type NavGroupHeadingProps = {
|
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||||
title: string;
|
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;
|
collapsed: boolean;
|
||||||
showDivider: boolean;
|
active: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function NavGroupHeading({ title, collapsed, showDivider }: NavGroupHeadingProps) {
|
function NavItem({ label, icon: Icon, path, collapsed, active, badge, onNavigate }: NavItemProps) {
|
||||||
if (collapsed) {
|
const [hovered, setHovered] = useState(false);
|
||||||
if (!showDivider) {
|
const isHover = hovered && !active;
|
||||||
return null;
|
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 1,
|
|
||||||
backgroundColor: "var(--color-sidebar-border)",
|
|
||||||
mx: "14px",
|
|
||||||
my: "10px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
return <Box sx={{ height: 8 }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Link
|
||||||
as="p"
|
to={path}
|
||||||
variant="label"
|
aria-current={active ? "page" : undefined}
|
||||||
family="display"
|
className="no-underline"
|
||||||
tone="muted"
|
title={collapsed ? label : undefined}
|
||||||
className="text-nav-group"
|
onClick={onNavigate}
|
||||||
sx={{
|
|
||||||
m: 0,
|
|
||||||
px: "10px",
|
|
||||||
pt: "14px",
|
|
||||||
pb: "6px",
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: "0.1em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
lineHeight: "normal",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{title}
|
<Box
|
||||||
</Text>
|
onMouseEnter={() => 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 && (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 8,
|
||||||
|
bottom: 8,
|
||||||
|
width: 3,
|
||||||
|
borderRadius: "0 3px 3px 0",
|
||||||
|
backgroundColor: "var(--primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<Box className="truncate flex-1">{label}</Box>
|
||||||
|
{badge != null && (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
className="inline-flex shrink-0 items-center justify-center"
|
||||||
|
sx={{
|
||||||
|
height: 18,
|
||||||
|
minWidth: 18,
|
||||||
|
px: "6px",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,10 +128,7 @@ type AppSidebarProps = {
|
||||||
export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: AppSidebarProps) {
|
export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: AppSidebarProps) {
|
||||||
const width = getSidebarWidth(collapsed);
|
const width = getSidebarWidth(collapsed);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { user } = useAuthContext();
|
|
||||||
const activePath = getActiveNavPath(pathname);
|
const activePath = getActiveNavPath(pathname);
|
||||||
const visibleGroups = getVisibleNavGroups(user?.userRoles);
|
|
||||||
const onNavigate = mobile ? onClose : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|
@ -121,25 +172,47 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
||||||
className="flex-1 overflow-y-auto overflow-x-hidden"
|
className="flex-1 overflow-y-auto overflow-x-hidden"
|
||||||
sx={{ py: 1 }}
|
sx={{ py: 1 }}
|
||||||
>
|
>
|
||||||
{visibleGroups.map((group, groupIndex) => (
|
{navGroups.map((group, groupIndex) => (
|
||||||
<Box key={group.title || `group-${groupIndex}`}>
|
<Box key={group.title || `group-${groupIndex}`}>
|
||||||
<NavGroupHeading
|
{collapsed ? (
|
||||||
title={group.title}
|
groupIndex > 0 && (
|
||||||
collapsed={collapsed}
|
<Box
|
||||||
showDivider={groupIndex > 0}
|
sx={{
|
||||||
/>
|
height: 1,
|
||||||
|
backgroundColor: "var(--color-sidebar-border)",
|
||||||
|
mx: "14px",
|
||||||
|
my: "10px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : group.title ? (
|
||||||
|
<Box
|
||||||
|
component="p"
|
||||||
|
className="text-nav-group"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
px: "10px",
|
||||||
|
pt: "12px",
|
||||||
|
pb: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ height: 8 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Box component="ul" sx={{ listStyle: "none", m: 0, p: "0 8px" }}>
|
<Box component="ul" sx={{ listStyle: "none", m: 0, p: "0 8px" }}>
|
||||||
{group.items.map(({ label, icon, path, badge }) => (
|
{group.items.map(({ label, icon, path, badge }) => (
|
||||||
<Box component="li" key={path} sx={{ listStyle: "none" }}>
|
<Box component="li" key={path} sx={{ listStyle: "none" }}>
|
||||||
<SidebarNavItem
|
<NavItem
|
||||||
label={label}
|
label={label}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
path={path}
|
path={path}
|
||||||
badge={badge}
|
badge={badge}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={path === activePath}
|
active={path === activePath}
|
||||||
onNavigate={onNavigate}
|
onNavigate={mobile ? onClose : undefined}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
@ -147,15 +220,6 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ borderTop: "1px solid var(--color-sidebar-border)", p: "8px" }}>
|
|
||||||
<SidebarNavItem
|
|
||||||
{...accountNavItem}
|
|
||||||
collapsed={collapsed}
|
|
||||||
active={accountNavItem.path === activePath}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
|
||||||
<Link
|
|
||||||
to={path}
|
|
||||||
aria-label={label}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
className="no-underline"
|
|
||||||
title={collapsed ? label : undefined}
|
|
||||||
onClick={onNavigate}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
onMouseEnter={() => 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 && (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 8,
|
|
||||||
bottom: 8,
|
|
||||||
width: 3,
|
|
||||||
borderRadius: "0 3px 3px 0",
|
|
||||||
backgroundColor: "var(--primary)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
|
|
||||||
{Boolean(!collapsed) && (
|
|
||||||
<>
|
|
||||||
<Box className="truncate flex-1">{label}</Box>
|
|
||||||
{visibleBadge != null && (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
className="inline-flex shrink-0 items-center justify-center"
|
|
||||||
sx={{
|
|
||||||
height: 18,
|
|
||||||
minWidth: 18,
|
|
||||||
px: "6px",
|
|
||||||
borderRadius: "9999px",
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "#ffffff",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visibleBadge}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
// BarChart2,
|
||||||
|
BriefcaseBusiness,
|
||||||
Building2,
|
Building2,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
DollarSign,
|
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
MapPin,
|
MapPin,
|
||||||
Settings,
|
Bell,
|
||||||
UserRound,
|
BarChart3,
|
||||||
Wrench,
|
Star,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
|
||||||
|
|
||||||
export type NavItemDef = {
|
export type NavItemDef = {
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
|
@ -23,66 +22,52 @@ export type NavItemDef = {
|
||||||
export type NavGroup = {
|
export type NavGroup = {
|
||||||
title: string;
|
title: string;
|
||||||
items: NavItemDef[];
|
items: NavItemDef[];
|
||||||
visible?: (userRoles: string | null | undefined) => boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SIDEBAR_WIDTH = 244;
|
export const SIDEBAR_WIDTH = 244;
|
||||||
|
|
||||||
export const SIDEBAR_COLLAPSED_WIDTH = 76;
|
export const SIDEBAR_COLLAPSED_WIDTH = 76;
|
||||||
|
|
||||||
export const accountNavItem: NavItemDef = {
|
|
||||||
label: "My Account",
|
|
||||||
icon: Settings,
|
|
||||||
path: "/account",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getSidebarWidth(collapsed: boolean): number {
|
export function getSidebarWidth(collapsed: boolean): number {
|
||||||
return collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH;
|
return collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
title: "OPERATIONS",
|
title: "",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Work Orders", icon: ClipboardList, path: "/workorders" },
|
{ label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 },
|
||||||
{ label: "Dashboard", icon: LayoutDashboard, path: "/" },
|
{ label: "Dashboard", icon: LayoutDashboard, path: "/" },
|
||||||
|
{ label: "Notifications", icon: Bell, path: "/notifications" },
|
||||||
|
// { label: "Reports", icon: BarChart2, path: "/reports" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "DATA MANAGEMENT",
|
title: "VENDORS",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Vendors", icon: Building2, path: "/vendors" },
|
{ label: "Vendors", icon: Building2, path: "/vendors" },
|
||||||
{ label: "Sites", icon: MapPin, path: "/locations" },
|
{ label: "Vendor POs", icon: FileText, path: "/vendor-pos" },
|
||||||
{ label: "Services", icon: Wrench, path: "/services" },
|
{ label: "Vendor Insights", icon: BarChart3, path: "/vendors/insights" },
|
||||||
{ label: "Completion Docs", icon: FileText, path: "/completion-docs" },
|
{ label: "Site Preferences", icon: Star, path: "/vendors/preferences" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "TEAM",
|
title: "CUSTOMERS & SITES",
|
||||||
visible: isAdminUser,
|
items: [
|
||||||
items: [{ label: "Team Members", icon: UserRound, path: "/employees" }],
|
{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" },
|
||||||
},
|
{ label: "Sites", icon: MapPin, path: "/locations" },
|
||||||
{
|
],
|
||||||
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 {
|
export function getActiveNavPath(pathname: string): string | undefined {
|
||||||
return collectNavPaths()
|
return navGroups
|
||||||
|
.flatMap((group) => group.items)
|
||||||
|
.map((item) => item.path)
|
||||||
.filter((path) =>
|
.filter((path) =>
|
||||||
path === "/" ? pathname === path : pathname === path || pathname.startsWith(`${path}/`),
|
path === "/" ? pathname === path : pathname === path || pathname.startsWith(`${path}/`),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ h6 {
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-nav-group {
|
.text-nav-group {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
|
|
|
||||||
|
|
@ -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(<PlaceholderPage title="Services" />, { withAuth: false });
|
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { name: "Services" })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("This page is under construction.")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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(
|
|
||||||
<AuthContext.Provider value={createAuthValue(userRoles)}>{ui}</AuthContext.Provider>,
|
|
||||||
{
|
|
||||||
withAuth: false,
|
|
||||||
route,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AppSidebar", () => {
|
|
||||||
it("renders the AAP tree plus My Account for Admin", () => {
|
|
||||||
renderSidebar(<AppSidebar collapsed={false} />, "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(<AppSidebar collapsed={false} />, "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(<AppSidebar collapsed />, "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(<AppSidebar collapsed={false} />, "Admin", "/uplifts");
|
|
||||||
|
|
||||||
expect(screen.getByRole("link", { name: "Uplift Approvals" })).toHaveAttribute(
|
|
||||||
"aria-current",
|
|
||||||
"page",
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("link", { name: "Work Orders" })).not.toHaveAttribute("aria-current");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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(
|
|
||||||
<SidebarNavItem
|
|
||||||
label="Work Orders"
|
|
||||||
icon={ClipboardList}
|
|
||||||
path="/workorders"
|
|
||||||
badge={0}
|
|
||||||
collapsed={false}
|
|
||||||
active={false}
|
|
||||||
/>,
|
|
||||||
{ withAuth: false, route: "/workorders" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole("link", { name: "Work Orders" })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("0")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a positive badge", () => {
|
|
||||||
renderWithProviders(
|
|
||||||
<SidebarNavItem
|
|
||||||
label="Uplift Approvals"
|
|
||||||
icon={ClipboardList}
|
|
||||||
path="/uplifts"
|
|
||||||
badge={2}
|
|
||||||
collapsed={false}
|
|
||||||
active={false}
|
|
||||||
/>,
|
|
||||||
{ withAuth: false, route: "/uplifts" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("2")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,79 +1,20 @@
|
||||||
import { getActiveNavPath, getVisibleBadge, getVisibleNavGroups, navGroups } from "@/config/menu";
|
import { getActiveNavPath } from "@/config/menu";
|
||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("getActiveNavPath", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["/", "/"],
|
["/", "/"],
|
||||||
["/workorders", "/workorders"],
|
|
||||||
["/workorders/42", "/workorders"],
|
|
||||||
["/vendors", "/vendors"],
|
["/vendors", "/vendors"],
|
||||||
["/vendors/42", "/vendors"],
|
["/vendors/42", "/vendors"],
|
||||||
["/vendors/insights", "/vendors"],
|
["/vendors/42/edit", "/vendors"],
|
||||||
["/locations", "/locations"],
|
["/vendors/insights", "/vendors/insights"],
|
||||||
["/services", "/services"],
|
["/vendors/preferences", "/vendors/preferences"],
|
||||||
["/completion-docs", "/completion-docs"],
|
["/vendor-pos/42", "/vendor-pos"],
|
||||||
["/employees", "/employees"],
|
|
||||||
["/employees/12", "/employees"],
|
|
||||||
["/uplifts", "/uplifts"],
|
|
||||||
["/account", "/account"],
|
|
||||||
])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => {
|
])("selects one longest boundary-matching route for %s", (pathname, expectedPath) => {
|
||||||
expect(getActiveNavPath(pathname)).toBe(expectedPath);
|
expect(getActiveNavPath(pathname)).toBe(expectedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not match partial route segments", () => {
|
it("does not match partial route segments", () => {
|
||||||
expect(getActiveNavPath("/vendor-posts")).toBeUndefined();
|
expect(getActiveNavPath("/vendor-posts")).toBeUndefined();
|
||||||
expect(getActiveNavPath("/accounts")).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue