diff --git a/apps/web/src/apps/modules/layouts/components/sidebar.tsx b/apps/web/src/apps/modules/layouts/components/sidebar.tsx index 455ce93..744c396 100644 --- a/apps/web/src/apps/modules/layouts/components/sidebar.tsx +++ b/apps/web/src/apps/modules/layouts/components/sidebar.tsx @@ -1,8 +1,8 @@ -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo, useState, useRef } from 'react'; import { Link, useLocation } from 'react-router-dom'; -import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu } from '@repo/ui/components'; +import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu, TextInput } from '@repo/ui/components'; import { useCoreAppShell } from '@repo/ui/components'; -import { ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { ChevronsLeft, ChevronsRight, Search, X } from 'lucide-react'; import type { MenuItemType } from '../types/menu.types'; import type { SidebarVariant } from '@repo/ui/components'; @@ -20,6 +20,8 @@ interface SidebarMenuProps { variantOverride?: SidebarVariant; /** Whether to show the collapse/expand toggle button (desktop only) */ withToggle?: boolean; + /** Whether to show the sticky menu filter input (default: true) */ + withMenuFilter?: boolean; } // --------------------------------------------------------------------------- @@ -29,16 +31,20 @@ interface SidebarMenuProps { interface MenuItemExpandedProps { item: MenuItemType; activeKeys: Set; + isSearching?: boolean; } -const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys }: MenuItemExpandedProps) { +const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys, isSearching }: 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; + const [opened, setOpened] = useState(isActive); + const isOpened = isSearching || opened; + return ( } active={isExactActive} - defaultOpened={isActive} // Auto-expand if active + opened={isOpened} + onChange={setOpened} variant="light" styles={{ - root: { + root: { borderRadius: 'var(--mantine-radius-md)', color: isParentActive ? 'var(--mantine-primary-color-filled)' : undefined, }, @@ -62,7 +69,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys }: Me > {hasChildren && item.children!.map((child) => ( - + ))} ); @@ -182,13 +189,22 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot = // SidebarMenu Component // --------------------------------------------------------------------------- -export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, withToggle = false }: SidebarMenuProps) { +export const SidebarMenu = memo(function SidebarMenu({ + items, + variantOverride, + withToggle = false, + withMenuFilter = true, +}: SidebarMenuProps) { const { pathname } = useLocation(); const { sidebarVariant: contextVariant, setSidebarVariant } = useCoreAppShell(); const variant = variantOverride ?? contextVariant; const isMini = variant === 'mini'; + const [searchQuery, setSearchQuery] = useState(''); + const inputRef = useRef(null); + const isSearching = searchQuery.trim().length > 0; + // Recursive active state calculation const activeKeys = useMemo(() => { const keys = new Set(); @@ -201,7 +217,7 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w 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 !== '') { @@ -213,19 +229,16 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w // 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; }; @@ -235,16 +248,75 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w return keys; }, [pathname, items]); + // Client-side recursive filtering logic + const filteredItems = useMemo(() => { + if (!isSearching) return items; + + const query = searchQuery.toLowerCase(); + + const filterItem = (item: MenuItemType): MenuItemType | null => { + const isMatch = item.label.toLowerCase().includes(query); + + if (item.children) { + const filteredChildren = item.children.map(filterItem).filter((child): child is MenuItemType => child !== null); + + // If parent matches, keep all its original children visible for standard UX + if (isMatch) { + return item; + } + + // If child matches, keep parent visible and show only matching children branches + if (filteredChildren.length > 0) { + return { ...item, children: filteredChildren }; + } + return null; + } + + return isMatch ? item : null; + }; + + return items.map(filterItem).filter((item): item is MenuItemType => item !== null); + }, [items, isSearching, searchQuery]); + const handleToggle = useCallback(() => { setSidebarVariant(isMini ? 'expanded' : 'mini'); }, [isMini, setSidebarVariant]); + const handleExpandAndSearch = useCallback(() => { + if (isMini) { + setSidebarVariant('expanded'); + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }, [isMini, setSidebarVariant]); + // -- Mini (Collapsed) Mode ------------------------------------------------ if (isMini) { return ( - - {items.map((item) => ( + {withMenuFilter && ( + + + + + + )} + + + {filteredItems.map((item) => ( ))} @@ -268,9 +340,41 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w // -- Expanded Mode -------------------------------------------------------- return ( - - {items.map((item) => ( - + {withMenuFilter && ( + + ) => setSearchQuery(e.currentTarget.value)} + leftSection={} + variant="filled" + radius="md" + size="xs" + rightSection={ + searchQuery ? ( + setSearchQuery('')} size="sm"> + + + ) : null + } + /> + + )} + + + {filteredItems.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 ed8b184..bb5dff3 100644 --- a/apps/web/src/apps/modules/layouts/data/menu.data.ts +++ b/apps/web/src/apps/modules/layouts/data/menu.data.ts @@ -10,6 +10,19 @@ import { Warehouse, Activity, Globe, + Briefcase, + Phone, + ShoppingCart, + Truck, + HardHat, + Factory, + Calculator, + Receipt, + PiggyBank, + Calendar, + Clock, + Shield, + FileSearch, } from 'lucide-react'; import type { MenuItemType } from '../types/menu.types'; @@ -27,58 +40,175 @@ export const MENU_ITEMS: MenuItemType[] = [ path: '/app/dashboard', }, { - key: 'master-data', - label: 'Master Data', - icon: Database, - path: '/app/master-data', + key: 'crm', + label: 'CRM', + icon: Users, + path: '/app/crm', 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: 'crm-leads', + label: 'Leads', + icon: Briefcase, + path: '/app/crm/leads', }, { - key: 'human-resources', - label: 'Human Resources', - icon: Users, - path: '/app/master-data/human-resources', + key: 'crm-pipelines', + label: 'Pipelines', + icon: Activity, + path: '/app/crm/pipelines', }, { - key: 'finance', - label: 'Finance', - icon: CreditCard, - path: '/app/master-data/finance', + key: 'crm-contacts', + label: 'Contacts', + icon: Phone, + path: '/app/crm/contacts', }, ], }, { - key: 'transactions', - label: 'Transactions', - icon: Activity, - path: '/app/transactions', + key: 'sales', + label: 'Sales', + icon: ShoppingCart, + path: '/app/sales', + children: [ + { + key: 'sales-quotations', + label: 'Quotations', + icon: FileText, + path: '/app/sales/quotations', + }, + { + key: 'sales-orders', + label: 'Sales Orders', + icon: Box, + path: '/app/sales/orders', + }, + { + key: 'sales-invoices', + label: 'Invoices', + icon: Receipt, + path: '/app/sales/invoices', + }, + ], + }, + { + key: 'supply-chain', + label: 'Supply Chain', + icon: Truck, + path: '/app/supply-chain', + children: [ + { + key: 'sc-inventory', + label: 'Inventory Management', + icon: Box, + path: '/app/supply-chain/inventory', + children: [ + { + key: 'sc-inventory-products', + label: 'Products', + icon: Layers, + path: '/app/supply-chain/inventory/products', + }, + { + key: 'sc-inventory-categories', + label: 'Categories', + icon: Globe, + path: '/app/supply-chain/inventory/categories', + }, + { + key: 'sc-inventory-adjustments', + label: 'Stock Adjustments', + icon: FileSearch, + path: '/app/supply-chain/inventory/adjustments', + }, + ], + }, + { + key: 'sc-warehouses', + label: 'Warehouses', + icon: Warehouse, + path: '/app/supply-chain/warehouses', + }, + { + key: 'sc-logistics', + label: 'Logistics', + icon: Globe, + path: '/app/supply-chain/logistics', + }, + ], + }, + { + key: 'manufacturing', + label: 'Manufacturing', + icon: Factory, + path: '/app/manufacturing', + children: [ + { + key: 'mfg-bom', + label: 'Bill of Materials', + icon: Layers, + path: '/app/manufacturing/bom', + }, + { + key: 'mfg-work-orders', + label: 'Work Orders', + icon: HardHat, + path: '/app/manufacturing/work-orders', + }, + ], + }, + { + key: 'hris', + label: 'HRIS', + icon: Briefcase, + path: '/app/hris', + children: [ + { + key: 'hris-employees', + label: 'Employees', + icon: Users, + path: '/app/hris/employees', + }, + { + key: 'hris-attendance', + label: 'Attendance', + icon: Clock, + path: '/app/hris/attendance', + }, + { + key: 'hris-payroll', + label: 'Payroll', + icon: CreditCard, + path: '/app/hris/payroll', + }, + { + key: 'hris-calendar', + label: 'Company Calendar', + icon: Calendar, + path: '/app/hris/calendar', + }, + ], + }, + { + key: 'accounting', + label: 'Accounting', + icon: Calculator, + path: '/app/accounting', + children: [ + { + key: 'acc-gl', + label: 'General Ledger', + icon: Database, + path: '/app/accounting/general-ledger', + }, + { + key: 'acc-taxes', + label: 'Taxes', + icon: PiggyBank, + path: '/app/accounting/taxes', + }, + ], }, - { key: 'settings', label: 'Settings & Configuration', @@ -86,10 +216,16 @@ export const MENU_ITEMS: MenuItemType[] = [ path: '/app/settings', children: [ { - key: 'long-text-1', - label: 'Configuration Management for External Vendors', + key: 'settings-general', + label: 'General Settings', icon: Settings, - path: '/app/settings/vendor-config', + path: '/app/settings/general', + }, + { + key: 'settings-security', + label: 'Security', + icon: Shield, + path: '/app/settings/security', }, { key: 'long-text-2',