From b21d647c8be74fd15ddd1b9dfdb161e9dc506ef5 Mon Sep 17 00:00:00 2001 From: shancheas Date: Thu, 27 Aug 2026 13:45:23 +0700 Subject: [PATCH] feat: implement coming soon pages and enhance menu structure - Added a new ComingSoonPage component for displaying a status page for features that are not yet available. - Integrated the ComingSoonPage into the sales and logistics modules for routes such as invoices, payments, packing slips, and reports. - Updated the MENU_ITEMS structure to include new nested items for sales and logistics, enhancing the organization of the menu. - Introduced placeholder functionality in the menu to manage visibility of items that are not yet available. - Updated navigation labels in both English and Indonesian to reflect new menu items and improve user experience. These changes enhance the application's navigation and user experience by clearly indicating upcoming features and improving menu organization. --- apps/web/src/apps/index.tsx | 12 +- .../apps/main/layouts/data/menu.data.test.ts | 72 +++ .../src/apps/main/layouts/data/menu.data.ts | 468 +++++++----------- .../apps/main/layouts/languages/en/nav.json | 10 +- .../apps/main/layouts/languages/id/nav.json | 10 +- .../src/apps/main/layouts/types/menu.types.ts | 2 + .../main/modules/field/logistics/index.tsx | 3 + .../web/src/apps/main/modules/sales/index.tsx | 4 + .../src/core/components/coming-soon-page.tsx | 29 ++ .../lib/filter-menu-by-view-privilege.test.ts | 49 +- .../core/lib/filter-menu-by-view-privilege.ts | 11 +- 11 files changed, 348 insertions(+), 322 deletions(-) create mode 100644 apps/web/src/apps/main/layouts/data/menu.data.test.ts create mode 100644 apps/web/src/core/components/coming-soon-page.tsx diff --git a/apps/web/src/apps/index.tsx b/apps/web/src/apps/index.tsx index e5d1f68..101d80d 100644 --- a/apps/web/src/apps/index.tsx +++ b/apps/web/src/apps/index.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from '@repo/ui/provider'; import { StatusPage, AgGridProvider } from '@repo/ui/components'; import { useTranslation } from '@repo/core-i18n'; import { LoadingScreen } from '../core/components/loading-screen'; +import { ComingSoonPage } from '../core/components/coming-soon-page'; import { useThemeStore } from '../core/stores/theme.store'; import { initializeAndPurgeHistoryBackground } from './main/layouts/hooks/useHistoryTracker'; @@ -53,17 +54,6 @@ function MaintenancePage() { ); } -function ComingSoonPage() { - const { t } = useTranslation(); - return ( - - ); -} - const router = createBrowserRouter([ { path: '/auth/*', element: }, { path: '/app/*', element: }, diff --git a/apps/web/src/apps/main/layouts/data/menu.data.test.ts b/apps/web/src/apps/main/layouts/data/menu.data.test.ts new file mode 100644 index 0000000..db28f99 --- /dev/null +++ b/apps/web/src/apps/main/layouts/data/menu.data.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import navEn from '../languages/en/nav.json'; +import navId from '../languages/id/nav.json'; +import { MENU_ITEMS } from './menu.data'; +import type { MenuItemType } from '../types/menu.types'; + +const childKeys = (item: MenuItemType | undefined): string[] => (item?.children ?? []).map((child) => child.key); + +const findItem = (items: MenuItemType[], key: string): MenuItemType | undefined => items.find((item) => item.key === key); + +const flatten = (items: MenuItemType[]): MenuItemType[] => + items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]); + +describe('MENU_ITEMS', () => { + it('orders top-level items as dashboard, sales, logistics, settings', () => { + expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'sales', 'logistics', 'settings']); + }); + + it('nests sales as data, activities, then reports', () => { + const sales = findItem(MENU_ITEMS, 'sales'); + + expect(childKeys(sales)).toEqual(['sales-data', 'sales-activities', 'sales-reports']); + expect(childKeys(findItem(sales?.children ?? [], 'sales-data'))).toEqual(['sales-employees', 'sales-cycles']); + expect(childKeys(findItem(sales?.children ?? [], 'sales-activities'))).toEqual([ + 'sales-requests', + 'sales-orders', + 'sales-invoices', + 'sales-payments', + 'sales-plans', + ]); + }); + + it('nests logistics as data, activities, then reports', () => { + const logistics = findItem(MENU_ITEMS, 'logistics'); + + expect(childKeys(logistics)).toEqual(['logistics-data', 'logistics-activities', 'logistics-reports']); + expect(childKeys(findItem(logistics?.children ?? [], 'logistics-data'))).toEqual([ + 'logistics-employees', + 'logistics-cycles', + ]); + expect(childKeys(findItem(logistics?.children ?? [], 'logistics-activities'))).toEqual([ + 'logistics-packing-slips', + 'logistics-plans', + ]); + }); + + it('nests settings as data then user', () => { + const settings = findItem(MENU_ITEMS, 'settings'); + + expect(childKeys(settings)).toEqual(['settings-data', 'settings-user']); + expect(childKeys(findItem(settings?.children ?? [], 'settings-data'))).toEqual([ + 'configuration-branches', + 'configuration-divisions', + 'configuration-customers', + 'configuration-products', + ]); + expect(childKeys(findItem(settings?.children ?? [], 'settings-user'))).toEqual(['system-users', 'system-privileges']); + }); + + it('uses unique keys across the whole tree', () => { + const keys = flatten(MENU_ITEMS).map((item) => item.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('resolves every label in both locales', () => { + for (const item of flatten(MENU_ITEMS)) { + const key = item.label.replace('nav:', ''); + expect(navEn).toHaveProperty(key); + expect(navId).toHaveProperty(key); + } + }); +}); diff --git a/apps/web/src/apps/main/layouts/data/menu.data.ts b/apps/web/src/apps/main/layouts/data/menu.data.ts index 61b1e57..a235059 100644 --- a/apps/web/src/apps/main/layouts/data/menu.data.ts +++ b/apps/web/src/apps/main/layouts/data/menu.data.ts @@ -7,24 +7,13 @@ import { Users, Box, Layers, - Warehouse, - Building2, MapPin, Activity, - Globe, - Briefcase, - Phone, ShoppingCart, Truck, - HardHat, - Factory, - Calculator, Receipt, - PiggyBank, Calendar, - Clock, Shield, - FileSearch, Repeat, Package, ClipboardList, @@ -44,77 +33,83 @@ export const MENU_ITEMS: MenuItemType[] = [ icon: LayoutDashboard, path: '/app/dashboard', }, - // { - // key: 'crm', - // label: 'nav:crm', - // icon: Users, - // path: '/app/crm', - // children: [ - // { - // key: 'crm-leads', - // label: 'nav:crm-leads', - // icon: Briefcase, - // path: '/app/crm/leads', - // }, - // { - // key: 'crm-pipelines', - // label: 'nav:crm-pipelines', - // icon: Activity, - // path: '/app/crm/pipelines', - // }, - // { - // key: 'crm-contacts', - // label: 'nav:crm-contacts', - // icon: Phone, - // path: '/app/crm/contacts', - // }, - // ], - // }, { key: 'sales', label: 'nav:sales', icon: ShoppingCart, path: '/app/sales', children: [ - // { - // key: 'sales-quotations', - // label: 'nav:sales-quotations', - // icon: FileText, - // path: '/app/sales/quotations', - // }, { - key: 'sales-requests', - label: 'nav:sales-requests', - icon: ClipboardList, - path: '/app/sales/requests/index', - moduleKey: 'SALES.REQUEST', + key: 'sales-data', + label: 'nav:data', + icon: Database, + path: '/app/sales/data', + children: [ + { + key: 'sales-employees', + label: 'nav:configuration-employees', + icon: Users, + path: '/app/configuration/employees/index', + moduleKey: 'CONFIGURATION.EMPLOYEE', + }, + { + key: 'sales-cycles', + label: 'nav:sales-cycles', + icon: Repeat, + path: '/app/sales/cycles/index', + moduleKey: 'SALES.CYCLE', + }, + ], }, { - key: 'sales-orders', - label: 'nav:sales-orders', - icon: Box, - path: '/app/sales/orders/index', - moduleKey: 'SALES.ORDER', + key: 'sales-activities', + label: 'nav:activities', + icon: Activity, + path: '/app/sales/activities', + children: [ + { + key: 'sales-requests', + label: 'nav:sales-requests', + icon: ClipboardList, + path: '/app/sales/requests/index', + moduleKey: 'SALES.REQUEST', + }, + { + key: 'sales-orders', + label: 'nav:sales-orders', + icon: Box, + path: '/app/sales/orders/index', + moduleKey: 'SALES.ORDER', + }, + { + key: 'sales-invoices', + label: 'nav:sales-invoices', + icon: Receipt, + path: '/app/sales/invoices', + moduleKey: 'SALES.INVOICE', + }, + { + key: 'sales-payments', + label: 'nav:sales-payments', + icon: CreditCard, + path: '/app/sales/payments', + moduleKey: 'SALES.PAYMENT', + }, + { + key: 'sales-plans', + label: 'nav:sales-plans', + icon: Calendar, + path: '/app/sales/plans/index', + moduleKey: 'SALES.PLAN', + }, + ], }, { - key: 'sales-invoices', - label: 'nav:sales-invoices', - icon: Receipt, - path: '/app/sales/invoices', - }, - { - key: 'sales-cycles', - label: 'nav:sales-cycles', - icon: Repeat, - path: '/app/sales/cycles/index', - moduleKey: 'SALES.CYCLE', - }, - { - key: 'sales-plans', - label: 'nav:sales-plans', - icon: Calendar, - path: '/app/sales/plans/index', - moduleKey: 'SALES.PLAN', + key: 'sales-reports', + label: 'nav:reports-coming-soon', + icon: FileText, + path: '/app/sales/reports', + isPlaceholder: true, }, ], }, @@ -125,249 +120,122 @@ export const MENU_ITEMS: MenuItemType[] = [ path: '/app/logistics', children: [ { - key: 'logistics-cycles', - label: 'nav:logistics-cycles', - icon: Repeat, - path: '/app/logistics/cycles/index', - moduleKey: 'LOGISTICS.CYCLE', + key: 'logistics-data', + label: 'nav:data', + icon: Database, + path: '/app/logistics/data', + children: [ + { + key: 'logistics-employees', + label: 'nav:configuration-employees', + icon: Users, + path: '/app/configuration/employees/index', + moduleKey: 'CONFIGURATION.EMPLOYEE', + }, + { + key: 'logistics-cycles', + label: 'nav:logistics-cycles', + icon: Repeat, + path: '/app/logistics/cycles/index', + moduleKey: 'LOGISTICS.CYCLE', + }, + ], }, { - key: 'logistics-plans', - label: 'nav:logistics-plans', - icon: Calendar, - path: '/app/logistics/plans/index', - moduleKey: 'LOGISTICS.PLAN', - }, - ], - }, - // { - // key: 'supply-chain', - // label: 'nav:supply-chain', - // icon: Truck, - // path: '/app/supply-chain', - // children: [ - // { - // key: 'sc-inventory', - // label: 'nav:sc-inventory', - // icon: Box, - // path: '/app/supply-chain/inventory', - // children: [ - // { - // key: 'sc-inventory-products', - // label: 'nav:sc-inventory-products', - // icon: Layers, - // path: '/app/supply-chain/inventory/products', - // }, - // { - // key: 'sc-inventory-categories', - // label: 'nav:sc-inventory-categories', - // icon: Globe, - // path: '/app/supply-chain/inventory/categories', - // }, - // { - // key: 'sc-inventory-adjustments', - // label: 'nav:sc-inventory-adjustments', - // icon: FileSearch, - // path: '/app/supply-chain/inventory/adjustments', - // }, - // ], - // }, - // { - // key: 'sc-warehouses', - // label: 'nav:sc-warehouses', - // icon: Warehouse, - // path: '/app/supply-chain/warehouses', - // }, - // { - // key: 'sc-logistics', - // label: 'nav:sc-logistics', - // icon: Globe, - // path: '/app/supply-chain/logistics', - // }, - // ], - // }, - // { - // key: 'manufacturing', - // label: 'nav:manufacturing', - // icon: Factory, - // path: '/app/manufacturing', - // children: [ - // { - // key: 'mfg-bom', - // label: 'nav:mfg-bom', - // icon: Layers, - // path: '/app/manufacturing/bom', - // }, - // { - // key: 'mfg-work-orders', - // label: 'nav:mfg-work-orders', - // icon: HardHat, - // path: '/app/manufacturing/work-orders', - // }, - // ], - // }, - // { - // key: 'hris', - // label: 'nav:hris', - // icon: Briefcase, - // path: '/app/hris', - // children: [ - // { - // key: 'hris-employees', - // label: 'nav:hris-employees', - // icon: Users, - // path: '/app/hris/employees', - // }, - // { - // key: 'hris-attendance', - // label: 'nav:hris-attendance', - // icon: Clock, - // path: '/app/hris/attendance', - // }, - // { - // key: 'hris-payroll', - // label: 'nav:hris-payroll', - // icon: CreditCard, - // path: '/app/hris/payroll', - // }, - // { - // key: 'hris-calendar', - // label: 'nav:hris-calendar', - // icon: Calendar, - // path: '/app/hris/calendar', - // }, - // ], - // }, - // { - // key: 'accounting', - // label: 'nav:accounting', - // icon: Calculator, - // path: '/app/accounting', - // children: [ - // { - // key: 'acc-gl', - // label: 'nav:acc-gl', - // icon: Database, - // path: '/app/accounting/general-ledger', - // }, - // { - // key: 'acc-taxes', - // label: 'nav:acc-taxes', - // icon: PiggyBank, - // path: '/app/accounting/taxes', - // }, - // ], - // }, - // { - // key: 'settings', - // label: 'nav:settings', - // icon: Settings, - // path: '/app/settings', - // children: [ - // { - // key: 'settings-general', - // label: 'nav:settings-general', - // icon: Settings, - // path: '/app/settings/general', - // }, - // { - // key: 'settings-security', - // label: 'nav:settings-security', - // icon: Shield, - // path: '/app/settings/security', - // }, - // { - // key: 'long-text-2', - // label: 'nav:long-text-2', - // icon: FileText, - // path: '/app/settings/long-menu-test', - // }, - // ], - // }, - { - key: 'configuration', - label: 'nav:configuration', - icon: Building2, - path: '/app/configuration', - children: [ - { - key: 'configuration-divisions', - label: 'nav:configuration-divisions', - icon: Layers, - path: '/app/configuration/divisions/index', - moduleKey: 'CONFIGURATION.DIVISION', + key: 'logistics-activities', + label: 'nav:activities', + icon: Activity, + path: '/app/logistics/activities', + children: [ + { + key: 'logistics-packing-slips', + label: 'nav:logistics-packing-slips', + icon: Package, + path: '/app/logistics/packing-slips', + moduleKey: 'SALES.PACKING_SLIP', + }, + { + key: 'logistics-plans', + label: 'nav:logistics-plans', + icon: Calendar, + path: '/app/logistics/plans/index', + moduleKey: 'LOGISTICS.PLAN', + }, + ], }, { - key: 'configuration-branches', - label: 'nav:configuration-branches', - icon: MapPin, - path: '/app/configuration/branches/index', - moduleKey: 'CONFIGURATION.BRANCH', - }, - { - key: 'configuration-customers', - label: 'nav:configuration-customers', - icon: Users, - path: '/app/configuration/customers/index', - moduleKey: 'CONFIGURATION.CUSTOMER', - }, - { - key: 'configuration-employees', - label: 'nav:configuration-employees', - icon: Users, - path: '/app/configuration/employees/index', - moduleKey: 'CONFIGURATION.EMPLOYEE', - }, - { - key: 'configuration-products', - label: 'nav:configuration-products', - icon: Package, - path: '/app/configuration/products/index', - moduleKey: 'CONFIGURATION.PRODUCT', + key: 'logistics-reports', + label: 'nav:reports-coming-soon', + icon: FileText, + path: '/app/logistics/reports', + isPlaceholder: true, }, ], }, { - key: 'system-group', - label: 'nav:system', - icon: Shield, - path: '/app/system', + key: 'settings', + label: 'nav:settings', + icon: Settings, + path: '/app/settings', children: [ { - key: 'system-privileges', - label: 'nav:system-privileges', - icon: Shield, - path: '/app/system/privileges/index', - moduleKey: 'PRIVILEGES', + key: 'settings-data', + label: 'nav:data', + icon: Database, + path: '/app/settings/data', + children: [ + { + key: 'configuration-branches', + label: 'nav:configuration-branches', + icon: MapPin, + path: '/app/configuration/branches/index', + moduleKey: 'CONFIGURATION.BRANCH', + }, + { + key: 'configuration-divisions', + label: 'nav:configuration-divisions', + icon: Layers, + path: '/app/configuration/divisions/index', + moduleKey: 'CONFIGURATION.DIVISION', + }, + { + key: 'configuration-customers', + label: 'nav:configuration-customers', + icon: Users, + path: '/app/configuration/customers/index', + moduleKey: 'CONFIGURATION.CUSTOMER', + }, + { + key: 'configuration-products', + label: 'nav:configuration-products', + icon: Package, + path: '/app/configuration/products/index', + moduleKey: 'CONFIGURATION.PRODUCT', + }, + ], }, { - key: 'system-users', - label: 'nav:system-users', + key: 'settings-user', + label: 'nav:user', icon: Users, - path: '/app/system/users/index', - moduleKey: 'USERS', + path: '/app/settings/user', + children: [ + { + key: 'system-users', + label: 'nav:system-users', + icon: Users, + path: '/app/system/users/index', + moduleKey: 'USERS', + }, + { + key: 'system-privileges', + label: 'nav:system-privileges', + icon: Shield, + path: '/app/system/privileges/index', + moduleKey: 'PRIVILEGES', + }, + ], }, ], }, - // { - // key: 'example-module', - // label: 'nav:example-module', - // icon: Database, - // path: '/app/example-module', - // children: [ - // { - // key: 'example-full-page', - // label: 'nav:example-full-page', - // icon: LayoutDashboard, - // path: '/app/example/full-page/index', - // moduleKey: 'EXAMPLE_FULL_PAGE', - // }, - // // { - // // key: 'example-single-page', - // // label: 'nav:example-single-page', - // // icon: FileText, - // // path: '/app/example/single-page/index', - // // }, - // ], - // }, ]; diff --git a/apps/web/src/apps/main/layouts/languages/en/nav.json b/apps/web/src/apps/main/layouts/languages/en/nav.json index 0a0047d..f98db11 100644 --- a/apps/web/src/apps/main/layouts/languages/en/nav.json +++ b/apps/web/src/apps/main/layouts/languages/en/nav.json @@ -8,7 +8,8 @@ "sales-quotations": "Quotations", "sales-requests": "Sales Requests", "sales-orders": "Sales Orders", - "sales-invoices": "Invoices", + "sales-invoices": "Sales Invoices", + "sales-payments": "Sales Payments", "supply-chain": "Supply Chain", "sc-inventory": "Inventory Management", "sc-inventory-products": "Products", @@ -27,7 +28,11 @@ "accounting": "Accounting", "acc-gl": "General Ledger", "acc-taxes": "Taxes", - "settings": "Settings & Configuration", + "data": "Data", + "activities": "Activities", + "reports-coming-soon": "Reports (Coming Soon)", + "user": "User", + "settings": "Settings", "settings-general": "General Settings", "settings-security": "Security", "long-text-2": "Extremely Long Menu Name To Test Text Truncation Handling Properly", @@ -46,6 +51,7 @@ "logistics": "Logistics", "logistics-cycles": "Logistics Cycles", "logistics-plans": "Logistics Plans", + "logistics-packing-slips": "Packing Slips", "configuration-employees": "Employees", "configuration-products": "Products" } diff --git a/apps/web/src/apps/main/layouts/languages/id/nav.json b/apps/web/src/apps/main/layouts/languages/id/nav.json index af189d4..b6a91f9 100644 --- a/apps/web/src/apps/main/layouts/languages/id/nav.json +++ b/apps/web/src/apps/main/layouts/languages/id/nav.json @@ -8,7 +8,8 @@ "sales-quotations": "Penawaran", "sales-requests": "Permintaan Penjualan", "sales-orders": "Pesanan Penjualan", - "sales-invoices": "Faktur", + "sales-invoices": "Faktur Penjualan", + "sales-payments": "Pembayaran Penjualan", "supply-chain": "Rantai Pasok", "sc-inventory": "Manajemen Inventaris", "sc-inventory-products": "Produk", @@ -27,7 +28,11 @@ "accounting": "Akuntansi", "acc-gl": "Buku Besar", "acc-taxes": "Pajak", - "settings": "Pengaturan & Konfigurasi", + "data": "Data", + "activities": "Aktivitas", + "reports-coming-soon": "Laporan (Segera Hadir)", + "user": "Pengguna", + "settings": "Pengaturan", "settings-general": "Pengaturan Umum", "settings-security": "Keamanan", "long-text-2": "Nama Menu Sangat Panjang Untuk Menguji Penanganan Pemotongan Teks Dengan Baik", @@ -46,6 +51,7 @@ "logistics": "Logistik", "logistics-cycles": "Siklus Logistik", "logistics-plans": "Rencana Logistik", + "logistics-packing-slips": "Surat Jalan", "configuration-employees": "Karyawan", "configuration-products": "Produk" } diff --git a/apps/web/src/apps/main/layouts/types/menu.types.ts b/apps/web/src/apps/main/layouts/types/menu.types.ts index 8bb9e2d..83e5c40 100644 --- a/apps/web/src/apps/main/layouts/types/menu.types.ts +++ b/apps/web/src/apps/main/layouts/types/menu.types.ts @@ -16,4 +16,6 @@ export interface MenuItemType { children?: MenuItemType[]; /** Privilege catalog key used to hide the item when ALLOW_VIEW is false */ moduleKey?: string; + /** Coming-soon leaf that must not keep an otherwise-empty parent visible */ + isPlaceholder?: boolean; } diff --git a/apps/web/src/apps/main/modules/field/logistics/index.tsx b/apps/web/src/apps/main/modules/field/logistics/index.tsx index fae816d..21ebc51 100644 --- a/apps/web/src/apps/main/modules/field/logistics/index.tsx +++ b/apps/web/src/apps/main/modules/field/logistics/index.tsx @@ -1,5 +1,6 @@ import { lazy } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; +import { EmbeddedComingSoonPage } from '../../../../../core/components/coming-soon-page'; const CyclesModule = lazy(() => import('../cycles/presentation/factory')); const PlansModule = lazy(() => import('../plans/presentation/factory')); @@ -9,6 +10,8 @@ export default function LogisticsFieldModule() { } /> } /> + } /> + } /> } /> ); diff --git a/apps/web/src/apps/main/modules/sales/index.tsx b/apps/web/src/apps/main/modules/sales/index.tsx index d1cb2f7..02bfcbf 100644 --- a/apps/web/src/apps/main/modules/sales/index.tsx +++ b/apps/web/src/apps/main/modules/sales/index.tsx @@ -1,5 +1,6 @@ import { lazy } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; +import { EmbeddedComingSoonPage } from '../../../../core/components/coming-soon-page'; const RequestsModule = lazy(() => import('./requests/presentation/factory')); const OrdersModule = lazy(() => import('./orders/presentation/factory')); @@ -13,6 +14,9 @@ export default function SalesModule() { } /> } /> } /> + } /> + } /> + } /> } /> ); diff --git a/apps/web/src/core/components/coming-soon-page.tsx b/apps/web/src/core/components/coming-soon-page.tsx new file mode 100644 index 0000000..ca1fdbf --- /dev/null +++ b/apps/web/src/core/components/coming-soon-page.tsx @@ -0,0 +1,29 @@ +import { StatusPage } from '@repo/ui/components'; +import { useTranslation } from '@repo/core-i18n'; + +interface ComingSoonPageProps { + /** Use inside ModuleLayout so the page fits under the app chrome */ + embedded?: boolean; +} + +export function ComingSoonPage({ embedded = false }: ComingSoonPageProps) { + const { t } = useTranslation(); + + return ( + + ); +} + +export function EmbeddedComingSoonPage() { + return ; +} diff --git a/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts b/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts index fe4546a..6b31a57 100644 --- a/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts +++ b/apps/web/src/core/lib/filter-menu-by-view-privilege.test.ts @@ -2,7 +2,15 @@ import { describe, expect, it } from 'vitest'; import { noPrivileges } from '@repo/ui/foundations'; import { filterMenuByViewPrivilege } from './filter-menu-by-view-privilege'; -const items = [ +type TestMenuItem = { + key: string; + path?: string; + moduleKey?: string; + isPlaceholder?: boolean; + children?: TestMenuItem[]; +}; + +const items: TestMenuItem[] = [ { key: 'system', children: [ @@ -58,4 +66,43 @@ describe('filterMenuByViewPrivilege', () => { const filtered = filterMenuByViewPrivilege(items, {}, false); expect(filtered.find((item) => item.key === 'dashboard')).toEqual({ key: 'dashboard', path: '/app/dashboard' }); }); + + it('drops a parent when the only remaining children are placeholders', () => { + const menu: TestMenuItem[] = [ + { + key: 'sales', + children: [ + { key: 'orders', moduleKey: 'SALES.ORDER', path: '/app/sales/orders/index' }, + { key: 'reports', path: '/app/sales/reports', isPlaceholder: true }, + ], + }, + ]; + + const filtered = filterMenuByViewPrivilege(menu, { 'SALES.ORDER': { ...noPrivileges } }, false); + + expect(filtered.find((item) => item.key === 'sales')).toBeUndefined(); + }); + + it('keeps placeholders when a real sibling remains', () => { + const menu: TestMenuItem[] = [ + { + key: 'sales', + children: [ + { key: 'orders', moduleKey: 'SALES.ORDER', path: '/app/sales/orders/index' }, + { key: 'reports', path: '/app/sales/reports', isPlaceholder: true }, + ], + }, + ]; + + const filtered = filterMenuByViewPrivilege( + menu, + { 'SALES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } }, + false, + ); + + expect(filtered.find((item) => item.key === 'sales')?.children?.map((child) => child.key)).toEqual([ + 'orders', + 'reports', + ]); + }); }); diff --git a/apps/web/src/core/lib/filter-menu-by-view-privilege.ts b/apps/web/src/core/lib/filter-menu-by-view-privilege.ts index 77c952d..89afdec 100644 --- a/apps/web/src/core/lib/filter-menu-by-view-privilege.ts +++ b/apps/web/src/core/lib/filter-menu-by-view-privilege.ts @@ -1,10 +1,8 @@ import type { PrivilegeEntity } from '@repo/ui/foundations'; -export function filterMenuByViewPrivilege( - items: T[], - privileges: Record, - isSuperadmin: boolean, -): T[] { +export function filterMenuByViewPrivilege< + T extends { moduleKey?: string; children?: T[]; isPlaceholder?: boolean }, +>(items: T[], privileges: Record, isSuperadmin: boolean): T[] { if (isSuperadmin) { return items; } @@ -17,7 +15,8 @@ export function filterMenuByViewPrivilege !child.isPlaceholder); + if (!hasRealChild) { return []; } return [{ ...item, children }];