feat: add menu filtering to sidebar and expand menu data with new items

This commit is contained in:
Firman Ramdhani
2026-07-03 14:48:19 +07:00
parent 5572bca964
commit eed10fb4d2
2 changed files with 303 additions and 63 deletions
@@ -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',
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',