mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): align sidebar and advanced filters with AAP
This commit is contained in:
parent
793230f0c2
commit
3bae52519f
22 changed files with 589 additions and 201 deletions
18
e2e/vendors/vendor-operations.spec.ts
vendored
18
e2e/vendors/vendor-operations.spec.ts
vendored
|
|
@ -38,16 +38,14 @@ test.describe("Vendor operations", () => {
|
|||
|
||||
await page.goto("/vendors/insights");
|
||||
|
||||
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");
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps Vendor PO controls usable at a constrained viewport", async ({ page }) => {
|
||||
|
|
|
|||
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 [
|
||||
{ path: "/workorders", label: "Work Orders" },
|
||||
{ path: "/accounts", label: "Accounts" },
|
||||
{ path: "/vendors", label: "Vendors" },
|
||||
{ path: "/locations", label: "Sites" },
|
||||
{ path: "/notifications", label: "Notifications" },
|
||||
{ path: "/account", label: "My Account" },
|
||||
]) {
|
||||
await page.goto(destination.path);
|
||||
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
|
||||
|
|
|
|||
5
src/app/(protected)/account/index.tsx
Normal file
5
src/app/(protected)/account/index.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { PlaceholderPage } from "@/components/common/placeholder-page";
|
||||
|
||||
export default function AccountPage() {
|
||||
return <PlaceholderPage title="My Account" />;
|
||||
}
|
||||
5
src/app/(protected)/completion-docs/index.tsx
Normal file
5
src/app/(protected)/completion-docs/index.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { PlaceholderPage } from "@/components/common/placeholder-page";
|
||||
|
||||
export default function CompletionDocsPage() {
|
||||
return <PlaceholderPage title="Completion Docs" />;
|
||||
}
|
||||
5
src/app/(protected)/services/index.tsx
Normal file
5
src/app/(protected)/services/index.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { PlaceholderPage } from "@/components/common/placeholder-page";
|
||||
|
||||
export default function ServicesPage() {
|
||||
return <PlaceholderPage title="Services" />;
|
||||
}
|
||||
|
|
@ -13,11 +13,7 @@ import {
|
|||
import { AdvDocPill, AdvStatusPill } from "./adv-filter-pill";
|
||||
import { AdvDateRangeSection } from "./adv-date-range-section";
|
||||
import { AdvMultiSelect } from "./adv-multi-select";
|
||||
import {
|
||||
AdvAvetaSection,
|
||||
AdvFlagInternalUpliftSections,
|
||||
AdvIndicatorsSection,
|
||||
} from "./adv-parity-filter-sections";
|
||||
import { AdvFlagInternalUpliftSections, AdvIndicatorsSection } from "./adv-parity-filter-sections";
|
||||
import { AdvSection } from "./adv-section";
|
||||
import { AdvSeverityList } from "./adv-severity-list";
|
||||
import { AdvTypeGrid } from "./adv-type-grid";
|
||||
|
|
@ -131,8 +127,6 @@ export function AdvancedSearchFilterSections({
|
|||
|
||||
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
|
||||
|
||||
<AdvAvetaSection draft={draft} setDraft={setDraft} />
|
||||
|
||||
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
|
||||
<AdvMultiSelect
|
||||
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,12 @@ export function AdvIndicatorsSection({ draft, setDraft }: DraftProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const FILTER_TOGGLE_CHECKBOX_SX = {
|
||||
padding: 0,
|
||||
color: "var(--color-border-hover)",
|
||||
"&.Mui-checked": { color: "var(--color-primary)" },
|
||||
} as const;
|
||||
|
||||
export function AdvAvetaSection({ draft, setDraft }: DraftProps) {
|
||||
return (
|
||||
<AdvSection icon={ShieldCheck} label="Aveta Compliance" count={draft.avetaOnly ? 1 : 0}>
|
||||
|
|
@ -133,11 +139,7 @@ export function AdvAvetaSection({ draft, setDraft }: DraftProps) {
|
|||
size="small"
|
||||
checked={draft.avetaOnly}
|
||||
onChange={() => setDraft((prev) => ({ ...prev, avetaOnly: !prev.avetaOnly }))}
|
||||
sx={{
|
||||
padding: 0,
|
||||
color: "var(--color-border-hover)",
|
||||
"&.Mui-checked": { color: "var(--warning)" },
|
||||
}}
|
||||
sx={FILTER_TOGGLE_CHECKBOX_SX}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Aveta required only</span>
|
||||
</label>
|
||||
|
|
@ -195,6 +197,8 @@ export function AdvFlagInternalUpliftSections({
|
|||
</button>
|
||||
</AdvSection>
|
||||
|
||||
<AdvAvetaSection draft={draft} setDraft={setDraft} />
|
||||
|
||||
<AdvSection icon={DollarSign} label="Uplift" count={draft.hasUplift ? 1 : 0}>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 select-none">
|
||||
<Checkbox
|
||||
|
|
@ -207,11 +211,7 @@ export function AdvFlagInternalUpliftSections({
|
|||
upliftStatuses: prev.hasUplift ? new Set() : prev.upliftStatuses,
|
||||
}))
|
||||
}
|
||||
sx={{
|
||||
padding: 0,
|
||||
color: "var(--color-border-hover)",
|
||||
"&.Mui-checked": { color: "var(--color-primary)" },
|
||||
}}
|
||||
sx={FILTER_TOGGLE_CHECKBOX_SX}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Has uplift</span>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -55,11 +55,17 @@ export function useAdvancedSearchDraft({
|
|||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
if (activeCount === 0) {
|
||||
return;
|
||||
}
|
||||
setDraft(cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS));
|
||||
onClearAll();
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (activeCount === 0) {
|
||||
return;
|
||||
}
|
||||
onApply(cloneAdvancedFilters(draft));
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -64,7 +64,12 @@ export function WorkOrdersAdvancedSearchSheet({
|
|||
/>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-t border-border px-4 py-3">
|
||||
<Button variant="ghost" className="text-xs" onClick={handleClearAll}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
disabled={activeCount === 0}
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<div className="flex flex-1 justify-center">
|
||||
|
|
@ -74,7 +79,7 @@ export function WorkOrdersAdvancedSearchSheet({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button className="px-5 text-xs" onClick={handleApply}>
|
||||
<Button className="px-5 text-xs" disabled={activeCount === 0} onClick={handleApply}>
|
||||
Apply filters
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
20
src/components/common/placeholder-page.tsx
Normal file
20
src/components/common/placeholder-page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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,120 +1,66 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { 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 { 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";
|
||||
|
||||
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 & {
|
||||
type NavGroupHeadingProps = {
|
||||
title: string;
|
||||
collapsed: boolean;
|
||||
active: boolean;
|
||||
onNavigate?: () => void;
|
||||
showDivider: boolean;
|
||||
};
|
||||
|
||||
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);
|
||||
function NavGroupHeading({ title, collapsed, showDivider }: NavGroupHeadingProps) {
|
||||
if (collapsed) {
|
||||
if (!showDivider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: 1,
|
||||
backgroundColor: "var(--color-sidebar-border)",
|
||||
mx: "14px",
|
||||
my: "10px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return <Box sx={{ height: 8 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={path}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="no-underline"
|
||||
title={collapsed ? label : undefined}
|
||||
onClick={onNavigate}
|
||||
<Text
|
||||
as="p"
|
||||
variant="label"
|
||||
family="display"
|
||||
tone="muted"
|
||||
className="text-nav-group"
|
||||
sx={{
|
||||
m: 0,
|
||||
px: "10px",
|
||||
pt: "14px",
|
||||
pb: "6px",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
lineHeight: "normal",
|
||||
}}
|
||||
>
|
||||
<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 }} />
|
||||
{!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>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +74,10 @@ 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 (
|
||||
<Drawer
|
||||
|
|
@ -172,47 +121,25 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
|||
className="flex-1 overflow-y-auto overflow-x-hidden"
|
||||
sx={{ py: 1 }}
|
||||
>
|
||||
{navGroups.map((group, groupIndex) => (
|
||||
{visibleGroups.map((group, groupIndex) => (
|
||||
<Box key={group.title || `group-${groupIndex}`}>
|
||||
{collapsed ? (
|
||||
groupIndex > 0 && (
|
||||
<Box
|
||||
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 }} />
|
||||
)}
|
||||
<NavGroupHeading
|
||||
title={group.title}
|
||||
collapsed={collapsed}
|
||||
showDivider={groupIndex > 0}
|
||||
/>
|
||||
|
||||
<Box component="ul" sx={{ listStyle: "none", m: 0, p: "0 8px" }}>
|
||||
{group.items.map(({ label, icon, path, badge }) => (
|
||||
<Box component="li" key={path} sx={{ listStyle: "none" }}>
|
||||
<NavItem
|
||||
<SidebarNavItem
|
||||
label={label}
|
||||
icon={icon}
|
||||
path={path}
|
||||
badge={badge}
|
||||
collapsed={collapsed}
|
||||
active={path === activePath}
|
||||
onNavigate={mobile ? onClose : undefined}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
|
|
@ -220,6 +147,15 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
|||
</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>
|
||||
</Drawer>
|
||||
);
|
||||
|
|
|
|||
128
src/components/layout/sidebar-nav-item.tsx
Normal file
128
src/components/layout/sidebar-nav-item.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,7 +33,9 @@ export function CollapsibleSection({
|
|||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon != null && <Icon size={16} className="text-primary" />}
|
||||
<span className="text-sm font-semibold text-foreground">{label}</span>
|
||||
<span className="text-[length:var(--text-base)] font-semibold text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CountBadge count={count} />
|
||||
|
|
|
|||
|
|
@ -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}/`),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||
advOpen = true;
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
|
||||
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(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
|
||||
advOpen = true;
|
||||
view.rerender(<WorkOrdersListPage />);
|
||||
|
||||
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(<WorkOrdersListPage />, {
|
||||
route: "/workorders",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
14
src/test/components/common/placeholder-page.test.tsx
Normal file
14
src/test/components/common/placeholder-page.test.tsx
Normal file
|
|
@ -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(<PlaceholderPage title="Services" />, { withAuth: false });
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Services" })).toBeInTheDocument();
|
||||
expect(screen.getByText("This page is under construction.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
96
src/test/components/layout/app-sidebar.test.tsx
Normal file
96
src/test/components/layout/app-sidebar.test.tsx
Normal file
|
|
@ -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(
|
||||
<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");
|
||||
});
|
||||
});
|
||||
41
src/test/components/layout/sidebar-nav-item.test.tsx
Normal file
41
src/test/components/layout/sidebar-nav-item.test.tsx
Normal file
|
|
@ -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(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
16
src/test/components/ui/collapsible-section.test.tsx
Normal file
16
src/test/components/ui/collapsible-section.test.tsx
Normal file
|
|
@ -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(<CollapsibleSection label="Aveta Compliance">child</CollapsibleSection>, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
const title = screen.getByRole("button", { name: /Aveta Compliance/i }).querySelector("span");
|
||||
expect(title).toHaveClass("text-[length:var(--text-base)]");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue