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 { 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,9 +31,10 @@ 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;
|
||||||
@@ -39,6 +42,9 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys }: Me
|
|||||||
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,7 +52,8 @@ 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: {
|
||||||
@@ -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>();
|
||||||
@@ -213,15 +229,12 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -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: 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,
|
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: [
|
children: [
|
||||||
{
|
{
|
||||||
key: 'products',
|
key: 'sc-inventory',
|
||||||
|
label: 'Inventory Management',
|
||||||
|
icon: Box,
|
||||||
|
path: '/app/supply-chain/inventory',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'sc-inventory-products',
|
||||||
label: 'Products',
|
label: 'Products',
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
path: '/app/master-data/inventory/products',
|
path: '/app/supply-chain/inventory/products',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'categories',
|
key: 'sc-inventory-categories',
|
||||||
label: 'Categories',
|
label: 'Categories',
|
||||||
icon: Globe,
|
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',
|
label: 'Warehouses',
|
||||||
icon: Warehouse,
|
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',
|
key: 'manufacturing',
|
||||||
label: 'Human Resources',
|
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,
|
icon: Users,
|
||||||
path: '/app/master-data/human-resources',
|
path: '/app/hris/employees',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'finance',
|
key: 'hris-attendance',
|
||||||
label: 'Finance',
|
label: 'Attendance',
|
||||||
|
icon: Clock,
|
||||||
|
path: '/app/hris/attendance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hris-payroll',
|
||||||
|
label: 'Payroll',
|
||||||
icon: CreditCard,
|
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',
|
key: 'accounting',
|
||||||
label: 'Transactions',
|
label: 'Accounting',
|
||||||
icon: Activity,
|
icon: Calculator,
|
||||||
path: '/app/transactions',
|
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',
|
||||||
|
|||||||
Reference in New Issue
Block a user