diff --git a/apps/web/src/apps/modules/layouts/components/sidebar.tsx b/apps/web/src/apps/modules/layouts/components/sidebar.tsx index 0e8fabd..455ce93 100644 --- a/apps/web/src/apps/modules/layouts/components/sidebar.tsx +++ b/apps/web/src/apps/modules/layouts/components/sidebar.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useMemo } from 'react'; import { Link, useLocation } from 'react-router-dom'; -import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider } from '@repo/ui/components'; +import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu } from '@repo/ui/components'; import { useCoreAppShell } from '@repo/ui/components'; import { ChevronsLeft, ChevronsRight } from 'lucide-react'; import type { MenuItemType } from '../types/menu.types'; @@ -28,50 +28,153 @@ interface SidebarMenuProps { interface MenuItemExpandedProps { item: MenuItemType; - isActive: boolean; + activeKeys: Set; } -const MenuItemExpanded = memo(function MenuItemExpanded({ item, isActive }: MenuItemExpandedProps) { +const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys }: MenuItemExpandedProps) { const Icon = item.icon; + const isActive = activeKeys.has(item.key); + const hasChildren = item.children && item.children.length > 0; + + const isExactActive = isActive && !hasChildren; + const isParentActive = isActive && hasChildren; + return ( } - active={isActive} + active={isExactActive} + defaultOpened={isActive} // Auto-expand if active variant="light" styles={{ - root: { borderRadius: 'var(--mantine-radius-md)' }, + root: { + borderRadius: 'var(--mantine-radius-md)', + color: isParentActive ? 'var(--mantine-primary-color-filled)' : undefined, + }, + label: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, }} - /> + > + {hasChildren && + item.children!.map((child) => ( + + ))} + ); }); // --------------------------------------------------------------------------- -// Individual Menu Item (Mini / Collapsed) +// Individual Menu Item (Mini / Collapsed / Flyout) // --------------------------------------------------------------------------- -interface MenuItemMiniProps { +interface MenuItemFlyoutProps { item: MenuItemType; - isActive: boolean; + activeKeys: Set; + isRoot?: boolean; } -const MenuItemMini = memo(function MenuItemMini({ item, isActive }: MenuItemMiniProps) { +const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot = true }: MenuItemFlyoutProps) { const Icon = item.icon; + const isActive = activeKeys.has(item.key); + const hasChildren = item.children && item.children.length > 0; + + // -- Leaf Item (No children) -- + if (!hasChildren) { + if (isRoot) { + return ( + + + + + + ); + } else { + return ( + } + color={isActive ? 'var(--mantine-primary-color-filled)' : undefined} + styles={{ + itemLabel: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: 240, + }, + }} + > + {item.label} + + ); + } + } + + // -- Parent Item (Has children) -- + const Target = isRoot ? ( + + + + ) : ( + } + rightSection={} + color={isActive ? 'var(--mantine-primary-color-filled)' : undefined} + closeMenuOnClick={false} // Mencegah parent tertutup saat memunculkan Level 3 + styles={{ + itemLabel: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: 240, + }, + }} + > + {item.label} + + ); + return ( - - - - - + + {Target} + + {isRoot && ( + <> + {item.label} + + + )} + {item.children!.map((child) => ( + + ))} + + ); }); @@ -86,17 +189,48 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w const variant = variantOverride ?? contextVariant; const isMini = variant === 'mini'; - // Determine active state: match if current path starts with the menu item's base path. - // Stripping trailing '/index' from item.path so parent routes also match child routes. + // Recursive active state calculation const activeKeys = useMemo(() => { const keys = new Set(); - for (const item of items) { - // e.g. item.path = '/app/example/full-page/index' - // basePath = '/app/example/full-page' + + const checkActive = (item: MenuItemType): boolean => { + let isActive = false; + + // 1. Exact match logic (handles trailing /index) const basePath = item.path.replace(/\/index$/, ''); - if (pathname === item.path || pathname.startsWith(basePath + '/') || pathname === basePath) { + if (pathname === item.path || pathname === basePath) { + isActive = true; + } + + // 2. Prefix match logic (for nested module routes like /detail/:id) + // We protect root paths like '/' or '/app' from matching everything. + if (!isActive && basePath !== '/' && basePath !== '/app' && basePath !== '') { + if (pathname.startsWith(basePath + '/')) { + isActive = true; + } + } + + // 3. Recursive children match + if (item.children) { + for (const child of item.children) { + // We intentionally avoid .some() here so checkActive runs for all children + // ensuring every active child gets its key added to the Set. + if (checkActive(child)) { + isActive = true; + } + } + } + + // If item or any of its children are active, mark this item as active + if (isActive) { keys.add(item.key); } + + return isActive; + }; + + for (const item of items) { + checkActive(item); } return keys; }, [pathname, items]); @@ -109,9 +243,9 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w if (isMini) { return ( - + {items.map((item) => ( - + ))} @@ -134,9 +268,9 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w // -- Expanded Mode -------------------------------------------------------- return ( - + {items.map((item) => ( - + ))} diff --git a/apps/web/src/apps/modules/layouts/data/menu.data.ts b/apps/web/src/apps/modules/layouts/data/menu.data.ts index 5209b55..ed8b184 100644 --- a/apps/web/src/apps/modules/layouts/data/menu.data.ts +++ b/apps/web/src/apps/modules/layouts/data/menu.data.ts @@ -1,4 +1,16 @@ -import { FileText, LayoutDashboard } from 'lucide-react'; +import { + FileText, + LayoutDashboard, + Database, + Settings, + CreditCard, + Users, + Box, + Layers, + Warehouse, + Activity, + Globe, +} from 'lucide-react'; import type { MenuItemType } from '../types/menu.types'; /** @@ -6,21 +18,105 @@ import type { MenuItemType } from '../types/menu.types'; * * Paths are absolute and must align with the router hierarchy: * BrowserRouter → /app/* → /example/* → /single-page/* | /full-page/* - * - * Each module's factory defines sub-routes (e.g. /index, /detail/:id). - * Menu items point to the default /index sub-route. */ export const MENU_ITEMS: MenuItemType[] = [ { - key: 'example-single-page', - label: 'Example Single Page', - icon: FileText, - path: '/app/example/single-page/index', + key: 'dashboard', + label: 'Dashboard', + icon: LayoutDashboard, + path: '/app/dashboard', }, { - key: 'example-full-page', - label: 'Example Full Page', - icon: LayoutDashboard, - path: '/app/example/full-page/index', + key: 'master-data', + label: 'Master Data', + icon: Database, + path: '/app/master-data', + children: [ + { + key: 'inventory', + label: 'Inventory', + icon: Box, + path: '/app/master-data/inventory', + children: [ + { + key: 'products', + label: 'Products', + icon: Layers, + path: '/app/master-data/inventory/products', + }, + { + key: 'categories', + label: 'Categories', + icon: Globe, + path: '/app/master-data/inventory/categories', + }, + { + key: 'warehouses', + label: 'Warehouses', + icon: Warehouse, + path: '/app/master-data/inventory/warehouses', + }, + ], + }, + { + key: 'human-resources', + label: 'Human Resources', + icon: Users, + path: '/app/master-data/human-resources', + }, + { + key: 'finance', + label: 'Finance', + icon: CreditCard, + path: '/app/master-data/finance', + }, + ], + }, + { + key: 'transactions', + label: 'Transactions', + icon: Activity, + path: '/app/transactions', + }, + + { + key: 'settings', + label: 'Settings & Configuration', + icon: Settings, + path: '/app/settings', + children: [ + { + key: 'long-text-1', + label: 'Configuration Management for External Vendors', + icon: Settings, + path: '/app/settings/vendor-config', + }, + { + key: 'long-text-2', + label: 'Extremely Long Menu Name To Test Text Truncation Handling Properly', + icon: FileText, + path: '/app/settings/long-menu-test', + }, + ], + }, + { + key: 'example-module', + label: 'Example Module', + icon: Database, + path: '/app/example-module', + children: [ + { + key: 'example-full-page', + label: 'Example Full Page', + icon: LayoutDashboard, + path: '/app/example/full-page/index', + }, + { + key: 'example-single-page', + label: 'Example Single Page', + icon: FileText, + path: '/app/example/single-page/index', + }, + ], }, ];