feat: add menu filtering to sidebar and expand menu data with new items
This commit is contained in:
@@ -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,9 +31,10 @@ interface SidebarMenuProps {
|
||||
interface MenuItemExpandedProps {
|
||||
item: MenuItemType;
|
||||
activeKeys: Set<string>;
|
||||
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;
|
||||
@@ -39,6 +42,9 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys }: Me
|
||||
const isExactActive = isActive && !hasChildren;
|
||||
const isParentActive = isActive && hasChildren;
|
||||
|
||||
const [opened, setOpened] = useState(isActive);
|
||||
const isOpened = isSearching || opened;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
component={hasChildren ? 'button' : (Link as any)}
|
||||
@@ -46,7 +52,8 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys }: Me
|
||||
label={item.label}
|
||||
leftSection={<Icon size={18} />}
|
||||
active={isExactActive}
|
||||
defaultOpened={isActive} // Auto-expand if active
|
||||
opened={isOpened}
|
||||
onChange={setOpened}
|
||||
variant="light"
|
||||
styles={{
|
||||
root: {
|
||||
@@ -62,7 +69,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys }: Me
|
||||
>
|
||||
{hasChildren &&
|
||||
item.children!.map((child) => (
|
||||
<MenuItemExpanded key={child.key} item={child} activeKeys={activeKeys} />
|
||||
<MenuItemExpanded key={child.key} item={child} activeKeys={activeKeys} isSearching={isSearching} />
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
|
||||
// Recursive active state calculation
|
||||
const activeKeys = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
@@ -213,15 +229,12 @@ 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);
|
||||
}
|
||||
@@ -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 (
|
||||
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Stack align="center" gap="xs" p="lg" style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{items.map((item) => (
|
||||
{withMenuFilter && (
|
||||
<Box
|
||||
p="xs"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'color-mix(in srgb, var(--mantine-color-body) 80%, transparent)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderBottom: '1px solid var(--mantine-color-default-border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<ActionIcon variant="light" size="lg" onClick={handleExpandAndSearch} aria-label="Search menu">
|
||||
<Search size={18} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack align="center" gap="xs" p="xs" pt="md" style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{filteredItems.map((item) => (
|
||||
<MenuItemFlyout key={item.key} item={item} activeKeys={activeKeys} />
|
||||
))}
|
||||
</Stack>
|
||||
@@ -268,9 +340,41 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w
|
||||
// -- Expanded Mode --------------------------------------------------------
|
||||
return (
|
||||
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box p="lg" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{items.map((item) => (
|
||||
<MenuItemExpanded key={item.key} item={item} activeKeys={activeKeys} />
|
||||
{withMenuFilter && (
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'color-mix(in srgb, var(--mantine-color-body) 80%, transparent)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderBottom: '1px solid var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
placeholder="Filter menu..."
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
|
||||
leftSection={<Search size={14} />}
|
||||
variant="filled"
|
||||
radius="md"
|
||||
size="xs"
|
||||
rightSection={
|
||||
searchQuery ? (
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setSearchQuery('')} size="sm">
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box p="lg" pt="md" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{filteredItems.map((item) => (
|
||||
<MenuItemExpanded key={item.key} item={item} activeKeys={activeKeys} isSearching={isSearching} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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',
|
||||
key: 'crm-leads',
|
||||
label: 'Leads',
|
||||
icon: Briefcase,
|
||||
path: '/app/crm/leads',
|
||||
},
|
||||
{
|
||||
key: 'crm-pipelines',
|
||||
label: 'Pipelines',
|
||||
icon: Activity,
|
||||
path: '/app/crm/pipelines',
|
||||
},
|
||||
{
|
||||
key: 'crm-contacts',
|
||||
label: 'Contacts',
|
||||
icon: Phone,
|
||||
path: '/app/crm/contacts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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/master-data/inventory',
|
||||
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: 'products',
|
||||
key: 'sc-inventory',
|
||||
label: 'Inventory Management',
|
||||
icon: Box,
|
||||
path: '/app/supply-chain/inventory',
|
||||
children: [
|
||||
{
|
||||
key: 'sc-inventory-products',
|
||||
label: 'Products',
|
||||
icon: Layers,
|
||||
path: '/app/master-data/inventory/products',
|
||||
path: '/app/supply-chain/inventory/products',
|
||||
},
|
||||
{
|
||||
key: 'categories',
|
||||
key: 'sc-inventory-categories',
|
||||
label: 'Categories',
|
||||
icon: Globe,
|
||||
path: '/app/master-data/inventory/categories',
|
||||
path: '/app/supply-chain/inventory/categories',
|
||||
},
|
||||
{
|
||||
key: 'warehouses',
|
||||
key: 'sc-inventory-adjustments',
|
||||
label: 'Stock Adjustments',
|
||||
icon: FileSearch,
|
||||
path: '/app/supply-chain/inventory/adjustments',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sc-warehouses',
|
||||
label: 'Warehouses',
|
||||
icon: Warehouse,
|
||||
path: '/app/master-data/inventory/warehouses',
|
||||
path: '/app/supply-chain/warehouses',
|
||||
},
|
||||
{
|
||||
key: 'sc-logistics',
|
||||
label: 'Logistics',
|
||||
icon: Globe,
|
||||
path: '/app/supply-chain/logistics',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'human-resources',
|
||||
label: 'Human Resources',
|
||||
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/master-data/human-resources',
|
||||
path: '/app/hris/employees',
|
||||
},
|
||||
{
|
||||
key: 'finance',
|
||||
label: 'Finance',
|
||||
key: 'hris-attendance',
|
||||
label: 'Attendance',
|
||||
icon: Clock,
|
||||
path: '/app/hris/attendance',
|
||||
},
|
||||
{
|
||||
key: 'hris-payroll',
|
||||
label: 'Payroll',
|
||||
icon: CreditCard,
|
||||
path: '/app/master-data/finance',
|
||||
path: '/app/hris/payroll',
|
||||
},
|
||||
{
|
||||
key: 'hris-calendar',
|
||||
label: 'Company Calendar',
|
||||
icon: Calendar,
|
||||
path: '/app/hris/calendar',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'transactions',
|
||||
label: 'Transactions',
|
||||
icon: Activity,
|
||||
path: '/app/transactions',
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user