From f1a2527a4916202b9ae93c3995c1d5bb7ba893cb Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 18 Jun 2026 11:30:09 -0300 Subject: [PATCH] Feat/ab/menu-and-header (#17) * chore(deps): add lucide-react for layout icon migration * feat(auth): add getPrimaryUserRole helper for header display * style(theme): add sidebar active tokens and nav group typography * refactor(menu): migrate nav icons to lucide and trim menu groups * feat(layout): redesign sidebar, topbar, and admin shell viewport * chore(menu): hide Reports and Documents from sidebar --------- Co-authored-by: Arthur Bassi --- package-lock.json | 16 ++ package.json | 1 + src/app/(protected)/_layout.tsx | 15 +- src/components/layout/app-sidebar.tsx | 272 +++++++++++++++----------- src/components/layout/app-topbar.tsx | 208 ++++++++++++-------- src/config/menu.ts | 74 +++---- src/lib/auth/user-utils.ts | 6 + src/styles/theme.css | 6 +- src/styles/typography.css | 10 + 9 files changed, 348 insertions(+), 260 deletions(-) create mode 100644 src/lib/auth/user-utils.ts diff --git a/package-lock.json b/package-lock.json index 9a26ebb5..00938eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@testing-library/user-event": "^13.5.0", "clsx": "^2.1.1", "ky": "^1.7.5", + "lucide-react": "^1.17.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-hook-form": "^7.78.0", @@ -5752,6 +5753,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -11255,6 +11265,12 @@ "yallist": "^3.0.2" } }, + "lucide-react": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "requires": {} + }, "lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/package.json b/package.json index bb81132f..98c6106c 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@testing-library/user-event": "^13.5.0", "clsx": "^2.1.1", "ky": "^1.7.5", + "lucide-react": "^1.17.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-hook-form": "^7.78.0", diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx index ee42fcc0..c9f0fcdf 100644 --- a/src/app/(protected)/_layout.tsx +++ b/src/app/(protected)/_layout.tsx @@ -3,31 +3,28 @@ import { Outlet } from "react-router-dom"; import { Box } from "@mui/material"; import { ProtectedRoute } from "@/components/auth/protected-route"; -import { - AppSidebar, - SIDEBAR_COLLAPSED_WIDTH, - SIDEBAR_WIDTH, -} from "@/components/layout/app-sidebar"; +import { AppSidebar } from "@/components/layout/app-sidebar"; +import { getSidebarWidth } from "@/config/menu"; import { AppTopbar } from "@/components/layout/app-topbar"; export default function AdminShellLayout() { const [navCollapsed, setNavCollapsed] = useState(false); - const sidebarWidth = navCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH; + const sidebarWidth = getSidebarWidth(navCollapsed); return ( - + setNavCollapsed((value) => !value)} /> diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 8f0d1c9a..56ec3a35 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -1,76 +1,117 @@ +import { useState } from "react"; import { NavLink } from "react-router-dom"; -import { - Box, - Drawer, - List, - ListItemButton, - ListItemIcon, - ListItemText, - Typography, -} from "@mui/material"; +import { Box, Drawer } from "@mui/material"; -import { navGroups, type NavItemDef } from "@/config/menu"; -import { cn } from "@/lib/utils"; +import { BrandLockup, BrandMark } from "@/components/common/brand-lockup"; +import { getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu"; -const SIDEBAR_WIDTH = 220; -const SIDEBAR_COLLAPSED_WIDTH = 56; +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; end?: boolean; }; -function NavItem({ label, icon: Icon, path, collapsed, end }: NavItemProps) { +function NavItem({ label, icon: Icon, path, collapsed, end, badge }: NavItemProps) { + const [hovered, setHovered] = useState(false); + return ( - - {({ isActive }) => ( - - + {({ isActive }) => { + const isHover = hovered && !isActive; + const { itemColor, iconColor, itemBg } = getNavItemAppearance(isActive, isHover); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} sx={{ - minWidth: collapsed ? 0 : 34, - justifyContent: "center", - color: "inherit", - mr: collapsed ? 0 : 0.5, + 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: isActive ? 600 : 500, + color: itemColor, + backgroundColor: itemBg, + boxShadow: isActive ? "var(--color-sidebar-item-active-shadow)" : "none", + transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)", + mb: "4px", + textAlign: "left", }} > - - - {!collapsed && ( - - )} - - )} + {isActive && ( + + )} + + {!collapsed && ( + <> + {label} + {badge != null && ( + + {badge} + + )} + + )} + + ); + }} ); } @@ -80,7 +121,7 @@ type AppSidebarProps = { }; export function AppSidebar({ collapsed }: AppSidebarProps) { - const width = collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH; + const width = getSidebarWidth(collapsed); return ( - + - + {collapsed ? : } - + {navGroups.map((group, groupIndex) => ( - - {!collapsed && ( - + {collapsed ? ( + groupIndex > 0 && ( + + ) + ) : group.title ? ( + {group.title} - + + ) : ( + )} - {collapsed && groupIndex > 0 && ( - - )} - - - {group.items.map(({ label, icon, path }) => ( - + + {group.items.map(({ label, icon, path, badge }) => ( + + + ))} - + ))} - + ); } - -export { SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_WIDTH }; diff --git a/src/components/layout/app-topbar.tsx b/src/components/layout/app-topbar.tsx index 80750b58..df8cbd4c 100644 --- a/src/components/layout/app-topbar.tsx +++ b/src/components/layout/app-topbar.tsx @@ -1,17 +1,11 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined"; -import MenuIcon from "@mui/icons-material/Menu"; -import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; -import PersonOutlineOutlinedIcon from "@mui/icons-material/PersonOutlineOutlined"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; import { AppBar, - Avatar, Box, Divider, - IconButton, ListItemIcon, ListItemText, Menu, @@ -19,8 +13,10 @@ import { Toolbar, Typography, } from "@mui/material"; +import { ChevronDown, Menu as MenuIcon } from "lucide-react"; import { useAuthContext } from "@/providers/auth-context"; +import { getPrimaryUserRole } from "@/lib/auth/user-utils"; type AppTopbarProps = { onToggleNav: () => void; @@ -30,7 +26,6 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) { const navigate = useNavigate(); const { user, logout } = useAuthContext(); - const [notifAnchor, setNotifAnchor] = useState(null); const [userAnchor, setUserAnchor] = useState(null); const handleLogout = () => { @@ -47,100 +42,133 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) { .toUpperCase() : "U"; + const userRole = getPrimaryUserRole(user?.userRoles); + return ( - - - - - - - - setNotifAnchor(event.currentTarget)} - sx={{ - color: "var(--color-header-text-muted)", - "&:hover": { - color: "var(--color-header-text)", - backgroundColor: "var(--color-header-chip-hover)", - }, - }} - > - - - - - - setUserAnchor(event.currentTarget)} - sx={{ - color: "var(--color-header-text-muted)", - "&:hover": { - color: "var(--color-header-text)", - backgroundColor: "var(--color-header-chip-hover)", - }, - }} - > - - - - - - setNotifAnchor(null)} - transformOrigin={{ horizontal: "right", vertical: "top" }} - anchorOrigin={{ horizontal: "right", vertical: "bottom" }} - slotProps={{ - paper: { - sx: { width: 300, mt: 1, boxShadow: "var(--shadow-lg)" }, - }, + className="flex items-center" + sx={{ + minHeight: "var(--spacing-topbar) !important", + height: "var(--spacing-topbar)", + gap: "18px", + px: "16px", + pr: "22px", }} > - - Notifications + + - - - - No new notifications - + + + + setUserAnchor(event.currentTarget)} + sx={{ + display: "flex", + alignItems: "center", + flexShrink: 0, + gap: "10px", + py: "5px", + pl: "6px", + pr: "10px", + borderRadius: "9999px", + border: "none", + cursor: "pointer", + backgroundColor: "transparent", + color: "var(--color-header-text)", + transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)", + "&:hover": { + backgroundColor: "var(--color-header-chip-hover)", + }, + }} + > + + {userInitials} + + + + {user?.fullname || "User"} + + + {userRole} + + + - + - {userInitials} - + + + {user?.fullname || "User"} + {user?.email || ""} diff --git a/src/config/menu.ts b/src/config/menu.ts index cc2bfdc9..f39c9879 100644 --- a/src/config/menu.ts +++ b/src/config/menu.ts @@ -1,24 +1,18 @@ +import type { LucideIcon } from "lucide-react"; import { - FaAddressBook, - FaBell, - FaClipboardList, - FaCog, - FaCubes, - FaDollarSign, - FaFileInvoiceDollar, - FaMapMarkerAlt, - FaMoneyCheckAlt, - FaTachometerAlt, - FaTools, - FaTruck, - FaUserTie, -} from "react-icons/fa"; -import type { IconType } from "react-icons"; + // BarChart2, + BriefcaseBusiness, + Building2, + ClipboardList, + FileText, + LayoutDashboard, +} from "lucide-react"; export type NavItemDef = { label: string; - icon: IconType; + icon: LucideIcon; path: string; + badge?: number; }; export type NavGroup = { @@ -26,42 +20,36 @@ export type NavGroup = { items: NavItemDef[]; }; +export const SIDEBAR_WIDTH = 244; + +export const SIDEBAR_COLLAPSED_WIDTH = 76; + +export function getSidebarWidth(collapsed: boolean): number { + return collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH; +} + export const navGroups: NavGroup[] = [ { - title: "Overview", - items: [{ label: "Dashboard", icon: FaTachometerAlt, path: "/" }], - }, - { - title: "Operations", + title: "", items: [ - { label: "Work Orders", icon: FaTools, path: "/workorders" }, - { label: "Uplifts", icon: FaDollarSign, path: "/uplifts" }, - { label: "Follow Ups", icon: FaBell, path: "/followups" }, - { label: "PM Schedules", icon: FaClipboardList, path: "/pmschedules" }, + { label: "Work Orders", icon: ClipboardList, path: "/workorders", badge: 11 }, + { label: "Dashboard", icon: LayoutDashboard, path: "/" }, + // { label: "Reports", icon: BarChart2, path: "/reports" }, ], }, { - title: "Vendors", + title: "VENDORS", items: [ - { label: "Vendors", icon: FaTruck, path: "/vendors" }, - { label: "Vendor POs", icon: FaFileInvoiceDollar, path: "/vendor-pos" }, + { label: "Vendors", icon: Building2, path: "/vendors" }, + { label: "Vendor POs", icon: FileText, path: "/vendor-pos" }, ], }, { - title: "Customers & Sites", - items: [ - { label: "Accounts", icon: FaMoneyCheckAlt, path: "/accounts" }, - { label: "Contacts", icon: FaAddressBook, path: "/contacts" }, - { label: "Locations", icon: FaMapMarkerAlt, path: "/locations" }, - { label: "Assets", icon: FaCubes, path: "/assets" }, - ], - }, - { - title: "Internal", - items: [{ label: "Employees", icon: FaUserTie, path: "/employees" }], - }, - { - title: "System", - items: [{ label: "Settings", icon: FaCog, path: "/settings/dropdowns" }], + title: "CUSTOMERS & SITES", + items: [{ label: "Accounts", icon: BriefcaseBusiness, path: "/accounts" }], }, + // { + // title: "INTERNAL", + // items: [{ label: "Documents", icon: FileText, path: "/documents" }], + // }, ]; diff --git a/src/lib/auth/user-utils.ts b/src/lib/auth/user-utils.ts new file mode 100644 index 00000000..98716ee7 --- /dev/null +++ b/src/lib/auth/user-utils.ts @@ -0,0 +1,6 @@ +export function getPrimaryUserRole( + userRoles: string | null | undefined, + fallback = "User", +): string { + return userRoles?.split(",")[0]?.trim() || fallback; +} diff --git a/src/styles/theme.css b/src/styles/theme.css index 787445be..0b950a4e 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -29,9 +29,9 @@ --tracking-wider: 0.08em; /* ── LAYOUT SPACING ─────────────────────────────────────────────── */ - --spacing-sidebar: 220px; + --spacing-sidebar: 244px; --spacing-topbar: 64px; - --spacing-sidebar-collapsed: 56px; + --spacing-sidebar-collapsed: 76px; /* ── RADIUS SCALE (scale resolves to 4 / 6 / 8 / 12) ────────────── */ --radius: 8px; @@ -122,6 +122,8 @@ --color-sidebar-border: var(--sidebar-border); --color-sidebar-badge-bg: #e6f4fb; --color-sidebar-badge-text: #155a92; + --color-sidebar-item-active-bg: var(--card); + --color-sidebar-item-active-shadow: 0 1px 3px rgba(38, 34, 98, 0.08); /* ── SEMANTIC (universal meaning) ───────────────────────────────── */ --color-destructive: var(--destructive); diff --git a/src/styles/typography.css b/src/styles/typography.css index 720652bb..34d61f9e 100644 --- a/src/styles/typography.css +++ b/src/styles/typography.css @@ -71,3 +71,13 @@ h6 { text-transform: uppercase; color: var(--color-sidebar-label); } + +.text-nav-group { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + line-height: normal; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted-foreground); +}