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 { 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 { 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 { MenuItemType } from '../types/menu.types';
import type { SidebarVariant } from '@repo/ui/components'; import type { SidebarVariant } from '@repo/ui/components';
@@ -20,6 +20,8 @@ interface SidebarMenuProps {
variantOverride?: SidebarVariant; variantOverride?: SidebarVariant;
/** Whether to show the collapse/expand toggle button (desktop only) */ /** Whether to show the collapse/expand toggle button (desktop only) */
withToggle?: boolean; withToggle?: boolean;
/** Whether to show the sticky menu filter input (default: true) */
withMenuFilter?: boolean;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -29,16 +31,20 @@ interface SidebarMenuProps {
interface MenuItemExpandedProps { interface MenuItemExpandedProps {
item: MenuItemType; item: MenuItemType;
activeKeys: Set<string>; 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 Icon = item.icon;
const isActive = activeKeys.has(item.key); const isActive = activeKeys.has(item.key);
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const isExactActive = isActive && !hasChildren; const isExactActive = isActive && !hasChildren;
const isParentActive = isActive && hasChildren; const isParentActive = isActive && hasChildren;
const [opened, setOpened] = useState(isActive);
const isOpened = isSearching || opened;
return ( return (
<NavLink <NavLink
component={hasChildren ? 'button' : (Link as any)} component={hasChildren ? 'button' : (Link as any)}
@@ -46,10 +52,11 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys }: Me
label={item.label} label={item.label}
leftSection={<Icon size={18} />} leftSection={<Icon size={18} />}
active={isExactActive} active={isExactActive}
defaultOpened={isActive} // Auto-expand if active opened={isOpened}
onChange={setOpened}
variant="light" variant="light"
styles={{ styles={{
root: { root: {
borderRadius: 'var(--mantine-radius-md)', borderRadius: 'var(--mantine-radius-md)',
color: isParentActive ? 'var(--mantine-primary-color-filled)' : undefined, color: isParentActive ? 'var(--mantine-primary-color-filled)' : undefined,
}, },
@@ -62,7 +69,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys }: Me
> >
{hasChildren && {hasChildren &&
item.children!.map((child) => ( item.children!.map((child) => (
<MenuItemExpanded key={child.key} item={child} activeKeys={activeKeys} /> <MenuItemExpanded key={child.key} item={child} activeKeys={activeKeys} isSearching={isSearching} />
))} ))}
</NavLink> </NavLink>
); );
@@ -182,13 +189,22 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot =
// SidebarMenu Component // 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 { pathname } = useLocation();
const { sidebarVariant: contextVariant, setSidebarVariant } = useCoreAppShell(); const { sidebarVariant: contextVariant, setSidebarVariant } = useCoreAppShell();
const variant = variantOverride ?? contextVariant; const variant = variantOverride ?? contextVariant;
const isMini = variant === 'mini'; const isMini = variant === 'mini';
const [searchQuery, setSearchQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const isSearching = searchQuery.trim().length > 0;
// Recursive active state calculation // Recursive active state calculation
const activeKeys = useMemo(() => { const activeKeys = useMemo(() => {
const keys = new Set<string>(); const keys = new Set<string>();
@@ -201,7 +217,7 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w
if (pathname === item.path || pathname === basePath) { if (pathname === item.path || pathname === basePath) {
isActive = true; isActive = true;
} }
// 2. Prefix match logic (for nested module routes like /detail/:id) // 2. Prefix match logic (for nested module routes like /detail/:id)
// We protect root paths like '/' or '/app' from matching everything. // We protect root paths like '/' or '/app' from matching everything.
if (!isActive && basePath !== '/' && basePath !== '/app' && basePath !== '') { if (!isActive && basePath !== '/' && basePath !== '/app' && basePath !== '') {
@@ -213,19 +229,16 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w
// 3. Recursive children match // 3. Recursive children match
if (item.children) { if (item.children) {
for (const child of 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)) { if (checkActive(child)) {
isActive = true; isActive = true;
} }
} }
} }
// If item or any of its children are active, mark this item as active
if (isActive) { if (isActive) {
keys.add(item.key); keys.add(item.key);
} }
return isActive; return isActive;
}; };
@@ -235,16 +248,75 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w
return keys; return keys;
}, [pathname, items]); }, [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(() => { const handleToggle = useCallback(() => {
setSidebarVariant(isMini ? 'expanded' : 'mini'); setSidebarVariant(isMini ? 'expanded' : 'mini');
}, [isMini, setSidebarVariant]); }, [isMini, setSidebarVariant]);
const handleExpandAndSearch = useCallback(() => {
if (isMini) {
setSidebarVariant('expanded');
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [isMini, setSidebarVariant]);
// -- Mini (Collapsed) Mode ------------------------------------------------ // -- Mini (Collapsed) Mode ------------------------------------------------
if (isMini) { if (isMini) {
return ( return (
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}> <Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<Stack align="center" gap="xs" p="lg" style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}> {withMenuFilter && (
{items.map((item) => ( <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} /> <MenuItemFlyout key={item.key} item={item} activeKeys={activeKeys} />
))} ))}
</Stack> </Stack>
@@ -268,9 +340,41 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w
// -- Expanded Mode -------------------------------------------------------- // -- Expanded Mode --------------------------------------------------------
return ( return (
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}> <Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<Box p="lg" style={{ flex: 1, overflowY: 'auto' }}> {withMenuFilter && (
{items.map((item) => ( <Box
<MenuItemExpanded key={item.key} item={item} activeKeys={activeKeys} /> 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> </Box>
@@ -10,6 +10,19 @@ import {
Warehouse, Warehouse,
Activity, Activity,
Globe, Globe,
Briefcase,
Phone,
ShoppingCart,
Truck,
HardHat,
Factory,
Calculator,
Receipt,
PiggyBank,
Calendar,
Clock,
Shield,
FileSearch,
} from 'lucide-react'; } from 'lucide-react';
import type { MenuItemType } from '../types/menu.types'; import type { MenuItemType } from '../types/menu.types';
@@ -27,58 +40,175 @@ export const MENU_ITEMS: MenuItemType[] = [
path: '/app/dashboard', path: '/app/dashboard',
}, },
{ {
key: 'master-data', key: 'crm',
label: 'Master Data', label: 'CRM',
icon: Database, icon: Users,
path: '/app/master-data', path: '/app/crm',
children: [ children: [
{ {
key: 'inventory', key: 'crm-leads',
label: 'Inventory', label: 'Leads',
icon: Box, icon: Briefcase,
path: '/app/master-data/inventory', path: '/app/crm/leads',
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', key: 'crm-pipelines',
label: 'Human Resources', label: 'Pipelines',
icon: Users, icon: Activity,
path: '/app/master-data/human-resources', path: '/app/crm/pipelines',
}, },
{ {
key: 'finance', key: 'crm-contacts',
label: 'Finance', label: 'Contacts',
icon: CreditCard, icon: Phone,
path: '/app/master-data/finance', path: '/app/crm/contacts',
}, },
], ],
}, },
{ {
key: 'transactions', key: 'sales',
label: 'Transactions', label: 'Sales',
icon: Activity, icon: ShoppingCart,
path: '/app/transactions', 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', key: 'settings',
label: 'Settings & Configuration', label: 'Settings & Configuration',
@@ -86,10 +216,16 @@ export const MENU_ITEMS: MenuItemType[] = [
path: '/app/settings', path: '/app/settings',
children: [ children: [
{ {
key: 'long-text-1', key: 'settings-general',
label: 'Configuration Management for External Vendors', label: 'General Settings',
icon: 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', key: 'long-text-2',