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', } 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. function isAdminHidden(key: string): boolean { return key.startsWith('Ct_') } 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 (
) }