import { Link, NavLink, Outlet, useLocation } from 'react-router-dom' import { ChevronDown, Circle, type LucideIcon } from 'lucide-react' import * as Icons from 'lucide-react' import { useState } from 'react' import { useAuth } from '@/contexts/AuthContext' import { TopBar } from '@/components/TopBar' import type { MenuNode } from '@/types/menu' import { cn } from '@/lib/cn' function getIcon(name: string | null): LucideIcon { if (!name) return Circle const candidate = (Icons as unknown as Record)[name] return candidate ?? Circle } // Map contract type code → ContractType int enum value (mirrors // Domain/Contracts/ContractType.cs for URL filter). const TYPE_CODE_TO_INT: Record = { ThauPhu: 1, GiaoKhoan: 2, NhaCungCap: 3, DichVu: 4, MuaBan: 5, NguyenTacNcc: 6, NguyenTacDv: 7, } // Resolve menu key → route. Static map for top-level items, pattern for // Ct__ sub-menu entries. function resolvePath(key: string): string | null { const staticMap: Record = { Dashboard: '/dashboard', Suppliers: '/master/suppliers', Projects: '/master/projects', Departments: '/master/departments', Contracts: '/contracts', Forms: '/forms', Reports: '/reports', Users: '/system/users', Roles: '/system/roles', Permissions: '/system/permissions', MenuVisibility: '/system/menu-visibility', Workflows: '/system/workflows', CatalogUnits: '/master/catalogs/units', CatalogMaterials: '/master/catalogs/materials', CatalogServices: '/master/catalogs/services', CatalogWorkItems: '/master/catalogs/work-items', PurchaseEvaluations: '/purchase-evaluations', PeWorkflows: '/system/pe-workflows', Budgets: '/budgets', Bg_List: '/budgets', Bg_Create: '/budgets/new', Bg_Pending: '/budgets?phase=Pending', // [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON // Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới // — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent. Hrm_HoSo: '/employees', // [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ. // 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap. Off_DanhBa: '/directory', } if (staticMap[key]) return staticMap[key] const match = key.match(/^Ct_([^_]+)_(List|Create|Pending)$/) if (match) { const [, code, action] = match const typeInt = TYPE_CODE_TO_INT[code] if (!typeInt) return null if (action === 'List') return `/contracts?type=${typeInt}` if (action === 'Create') return `/contracts/new?type=${typeInt}` if (action === 'Pending') return `/contracts?type=${typeInt}&pendingMe=1` } // Workflow admin per ContractType: Wf_ → /system/workflows/ const wfMatch = key.match(/^Wf_(.+)$/) if (wfMatch) { const code = wfMatch[1] if (TYPE_CODE_TO_INT[code]) return `/system/workflows/${code}` } // Pe__ cho module Duyệt NCC const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending)$/) if (peMatch) { const [, code, action] = peMatch const PE_CODE_TO_INT: Record = { DuyetNcc: 1, DuyetNccPhuongAn: 2 } const typeInt = PE_CODE_TO_INT[code] if (!typeInt) return null if (action === 'List') return `/purchase-evaluations?type=${typeInt}` // "Thao tác" leaf → workspace 2-panel (Q4 2026-05-07): pick + create + sửa // tables inline. Header-only `/new` page giữ tồn tại cho deep-link cũ // (PeDetailTabs "Sửa header" button vẫn navigate sang đó). if (action === 'Create') return `/purchase-evaluations/workspace?type=${typeInt}` if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1` } // PE workflow admin leaf: PeWf_ → /system/pe-workflows/ const peWfMatch = key.match(/^PeWf_(.+)$/) if (peWfMatch) { const code = peWfMatch[1] if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn') return `/system/pe-workflows/${code}` } // Quy trình duyệt MỚI (Mig 22 — Session 17): root = group bowed, leaf = // type-specific designer. Sau UAT thay thế PeWorkflows + Workflows cũ. if (key === 'ApprovalWorkflowsV2') return '/system/approval-workflows-v2' const awV2Match = key.match(/^AwV2_(.+)$/) if (awV2Match) { const code = awV2Match[1] if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn' || code === 'Contract') { return `/system/approval-workflows-v2/${code}` } } return null } // Admin side: hide the per-ContractType contract submenu (Ct_*) — that's a // user-app concern. Keep Wf_* workflow-admin leaves. // [Plan CA S29 2026-05-22] cũng hide "Cấu hình danh mục dùng chung" (Master + // Catalogs) — đã move sang fe-user/eoffice. Admin vẫn phân quyền role × menu × // CRUD qua /system/permissions (Permission Matrix tự reflect 9 menu key này). const ADMIN_HIDDEN_MASTER_KEYS = new Set([ 'Master', 'Suppliers', 'Projects', 'Departments', 'Catalogs', 'CatalogUnits', 'CatalogMaterials', 'CatalogServices', 'CatalogWorkItems', ]) function isAdminHidden(key: string): boolean { return key.startsWith('Ct_') || ADMIN_HIDDEN_MASTER_KEYS.has(key) } function filterForAdmin(nodes: MenuNode[]): MenuNode[] { return nodes .filter(n => !isAdminHidden(n.key)) .map(n => ({ ...n, children: filterForAdmin(n.children) })) } function MenuNodeRenderer({ node, depth = 0 }: { node: MenuNode; depth?: number }) { const hasChildren = node.children.length > 0 if (hasChildren) return return } function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) { // Top-level groups expanded by default, nested ones collapsed to reduce noise const [open, setOpen] = useState(depth === 0) const Icon = getIcon(node.icon) const isTopLevel = depth === 0 return (
{open && (
{node.children.map(c => ( ))}
)}
) } // Transient query keys — không phải "navigation identity", strip trước khi // compare để menu giữ highlight khi user select row / search / filter. // Ví dụ leaf "Danh sách" `?type=1` vẫn highlight khi user click phiếu → // `?type=1&id=abc`. Trước đó exact-set match → mất highlight (bug UAT 2026-05-08). const TRANSIENT_QUERY_KEYS = new Set(['id', 'q', 'editHeader', 'page', 'phase', 'awId']) // So sánh 2 query string dạng key-value set (thứ tự param không quan trọng, // transient keys ignored). Fix bug: /contracts?type=1 và ?type=1&pendingMe=1 // cùng highlight vì NavLink built-in `end` prop chỉ match pathname. function queryMatches(current: string, target: string): boolean { const a = new URLSearchParams(current) const b = new URLSearchParams(target) const aKeys = [...a.keys()].filter(k => !TRANSIENT_QUERY_KEYS.has(k)).sort() const bKeys = [...b.keys()].filter(k => !TRANSIENT_QUERY_KEYS.has(k)).sort() if (aKeys.length !== bKeys.length) return false return aKeys.every((k, i) => bKeys[i] === k && a.get(k) === b.get(k)) } function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) { const Icon = getIcon(node.icon) const path = resolvePath(node.key) const location = useLocation() if (!path) return null const isDeep = depth >= 2 const [targetPath, targetQuery = ''] = path.split('?') const isActive = location.pathname === targetPath && queryMatches(location.search.replace(/^\?/, ''), targetQuery) return ( {/* [Plan AA S24 t2 wrap fix] inline-block icon + inline text → mirror fe-user. */} {node.label} ) } export function Layout() { const { menu } = useAuth() return (
) }