import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { Link, NavLink, Outlet, useLocation } from 'react-router-dom' import { ChevronDown, Circle, type LucideIcon } from 'lucide-react' import * as Icons from 'lucide-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 } const TYPE_CODE_TO_INT: Record = { ThauPhu: 1, GiaoKhoan: 2, NhaCungCap: 3, DichVu: 4, MuaBan: 5, NguyenTacNcc: 6, NguyenTacDv: 7, } // Reverse lookup int → code (cho auto-expand từ URL ?type=) const INT_TO_TYPE_CODE: Record = Object.fromEntries( Object.entries(TYPE_CODE_TO_INT).map(([k, v]) => [v, k]), ) // Detect Ct_ group key (no suffix, distinguish from leaf Ct__List/Create/Pending) const CT_GROUP_PATTERN = /^Ct_([^_]+)$/ function getCtGroupCode(key: string): string | null { const m = key.match(CT_GROUP_PATTERN) return m ? m[1] : null } // Pe_ group key (Duyệt NCC / Duyệt NCC và Giải pháp). Riêng accordion // để Pe_* mutex với nhau (không mix với Ct_*) — user có thể expand 1 Ct_ HĐ // + 1 Pe_ phiếu cùng lúc vì 2 module khác nhau. const PE_GROUP_PATTERN = /^Pe_([^_]+)$/ function getPeGroupCode(key: string): string | null { const m = key.match(PE_GROUP_PATTERN) return m ? m[1] : null } // PE type code → int (mirror BE PurchaseEvaluationType enum) const PE_CODE_TO_INT: Record = { DuyetNcc: 1, DuyetNccPhuongAn: 2 } const INT_TO_PE_CODE: Record = Object.fromEntries( Object.entries(PE_CODE_TO_INT).map(([k, v]) => [v, k]), ) // User-side menu key → route. Differs from admin: Danh sách points to // /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval). function resolvePath(key: string): string | null { const staticMap: Record = { Dashboard: '/dashboard', Contracts: '/my-contracts', PurchaseEvaluations: '/purchase-evaluations', Budgets: '/budgets', Bg_List: '/budgets', Bg_Create: '/budgets/new', Bg_Pending: '/budgets?phase=Pending', // [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ // fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không // MenuLeaf line 238 `if (!path) return null` → sidebar drop silent. // Implementer Chunk B (06a441c) thêm Routes vào App.tsx + 4 page + // menuKeys.ts nhưng quên mirror staticMap resolvePath → bug UAT bro. Suppliers: '/master/suppliers', Projects: '/master/projects', Departments: '/master/departments', CatalogUnits: '/master/catalogs/units', CatalogMaterials: '/master/catalogs/materials', CatalogServices: '/master/catalogs/services', CatalogWorkItems: '/master/catalogs/work-items', // [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 ~250 `if (!path) return null` → sidebar drop silent. Hrm_HoSo: '/employees', // [Phase 10.2 G-H2 S35 2026-05-28] Cấu hình HRM 4 catalog leaf (Mig 35). // Pattern 16-bis 4-place mirror (staticMap = 4th place, dễ miss nhất). Hrm_Config_LeaveTypes: '/hrm/configs/leave-types', Hrm_Config_Holidays: '/hrm/configs/holidays', Hrm_Config_Shifts: '/hrm/configs/shifts', Hrm_Config_OtPolicies: '/hrm/configs/ot-policies', // [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', // [Phase 10.2 G-O2 S36 2026-05-28] Phòng họp Booking + Catalog (Mig 36). // Pattern 16-bis 4-place mirror 7× cumulative — staticMap = 4th place dễ miss. // View leaf + Book leaf đều trỏ calendar (user perspective); Manage trỏ catalog. Off_PhongHop_View: '/meeting-calendar', Off_PhongHop_Book: '/meeting-calendar', Off_PhongHop_Manage: '/meeting-rooms', Off_DeXuat_List: '/proposals', Off_DeXuat_Create: '/proposals/new', Off_DeXuat_Inbox: '/proposals?status=2&inboxOnly=true', } 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 `/my-contracts?type=${typeInt}` if (action === 'Create') return `/contracts/new?type=${typeInt}` if (action === 'Pending') return `/inbox?type=${typeInt}` } // Pe__ cho module Duyệt NCC (user side) const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending|WfView)$/) if (peMatch) { const [, code, action] = peMatch const typeInt = PE_CODE_TO_INT[code] if (!typeInt) return null // [Plan AA S24 t1] "Luồng duyệt" leaf — read-only matrix view phía trên // Danh sách. User xem trước khi tạo phiếu ai duyệt step nào (filter // workflow IsUserSelectable=true admin ghim). if (action === 'WfView') return `/purchase-evaluations/workflow-matrix?type=${typeInt}` 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` } return null } // Menu entries not applicable to user app — filtered out client-side so the // sidebar only shows what matters to a contract drafter/approver. // [Plan CA S29 2026-05-22] REMOVED `Master, Suppliers, Projects, Departments` // khỏi hidden set — "Cấu hình danh mục dùng chung" giờ ở fe-user/eoffice cho // role CatalogManager (admin tạo + gán qua Permission Matrix). Catalogs (root // + 4 leaf) tree-inherit từ Master nên auto-visible. Forms/Reports/System/ // Users/Roles/Permissions vẫn ẩn (admin tools only). const USER_HIDDEN_KEYS = new Set([ 'System', 'Users', 'Roles', 'Permissions', 'Forms', 'Reports', ]) function filterForUser(nodes: MenuNode[]): MenuNode[] { // Filter 2 tầng: hardcode USER_HIDDEN_KEYS (system-level, structural never-show) // + dynamic isVisible (Mig 27 admin toggle qua MenuVisibilityPage). isVisible // mặc định true, admin set false → ẩn khỏi sidebar eOffice. return nodes .filter(n => !USER_HIDDEN_KEYS.has(n.key) && n.isVisible !== false) .map(n => ({ ...n, children: filterForUser(n.children) })) } // Mig 27: ưu tiên displayLabel admin custom, fallback label gốc. function effectiveLabel(n: { label: string; displayLabel?: string | null }): string { return (n.displayLabel && n.displayLabel.trim()) || n.label } // Accordion state cho groups Ct_ (7 HĐ) + Pe_ (2 phiếu) — mỗi // family mutex độc lập. Auto-expand theo URL `?type=X` (Ct_ dùng route // /contracts|/my-contracts|/inbox, Pe_ dùng /purchase-evaluations). Group // không match prefix giữ behavior cũ (independent local useState). type AccordionContextValue = { expandedCtCode: string | null setExpandedCtCode: (code: string | null) => void expandedPeCode: string | null setExpandedPeCode: (code: string | null) => void } const AccordionContext = createContext({ expandedCtCode: null, setExpandedCtCode: () => {}, expandedPeCode: null, setExpandedPeCode: () => {}, }) 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 }) { const ctCode = getCtGroupCode(node.key) const peCode = getPeGroupCode(node.key) const accordion = useContext(AccordionContext) // Local state cho group thường (top-level "Hợp đồng" mặc định mở) const [localOpen, setLocalOpen] = useState(depth === 0) // Ct_ / Pe_ group: state controlled bởi accordion context, // mỗi family (Ct / Pe) mutex độc lập. const isCtAccordion = ctCode != null const isPeAccordion = peCode != null const isAccordion = isCtAccordion || isPeAccordion const open = isCtAccordion ? accordion.expandedCtCode === ctCode : isPeAccordion ? accordion.expandedPeCode === peCode : localOpen function toggle() { if (isCtAccordion) { accordion.setExpandedCtCode(open ? null : ctCode) } else if (isPeAccordion) { accordion.setExpandedPeCode(open ? null : peCode) } else { setLocalOpen(o => !o) } } 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). Dùng để distinguish /path?type=2 vs /path?type=2&pendingMe=1. 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 // Custom active check: pathname match + query string match (set equality). // Fix bug: /purchase-evaluations?type=2 và ?type=2&pendingMe=1 cùng highlight // vì NavLink default chỉ check pathname. const [targetPath, targetQuery = ''] = path.split('?') const pathnameMatches = location.pathname === targetPath const qMatches = queryMatches(location.search.replace(/^\?/, ''), targetQuery) const isActive = pathnameMatches && qMatches return ( {/* [Plan AA S24 t2 wrap fix] inline-block icon + inline text → label dài wrap về đầu hàng (under icon, KHÔNG indent sau icon). */} {effectiveLabel(node)} ) } // Static entries prepended to the dynamic menu tree — these are user-app // specific (inbox + quick create) not backed by MenuItems DB rows. const USER_FIXED_TOP: MenuNode[] = [ { key: '__inbox', label: 'Hộp thư', parentKey: null, order: 0, icon: 'Inbox', canRead: true, canCreate: true, canUpdate: true, canDelete: true, isVisible: true, displayLabel: null, children: [] }, ] function staticResolvePath(key: string): string | null { if (key === '__inbox') return '/inbox' return null } function StaticLeaf({ node }: { node: MenuNode }) { const Icon = getIcon(node.icon) const path = staticResolvePath(node.key) if (!path) return null return ( cn( 'block rounded-md px-3 py-2 text-[12px] font-medium leading-snug transition', isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900', ) } > {/* [Plan AA S24 t2 wrap fix] inline-block icon + inline text — consistent với MenuLeaf + MenuGroup. */} {effectiveLabel(node)} ) } export function Layout() { const { menu } = useAuth() const filteredMenu = filterForUser(menu) const location = useLocation() // Accordion state — 1 Ct_ HĐ + 1 Pe_ phiếu expand tối đa. Auto-sync theo // URL: /contracts|/my-contracts|/inbox?type=N → Ct, /purchase-evaluations // ?type=N → Pe. Khác pathname gốc → skip (không reset, giữ user choice). const [expandedCtCode, setExpandedCtCode] = useState(null) const [expandedPeCode, setExpandedPeCode] = useState(null) useEffect(() => { const params = new URLSearchParams(location.search) const typeParam = params.get('type') if (!typeParam) return const n = Number(typeParam) if (location.pathname.startsWith('/purchase-evaluations')) { const code = INT_TO_PE_CODE[n] if (code) setExpandedPeCode(code) } else { // /contracts, /my-contracts, /inbox, /contracts/new... — all use // ContractType enum const code = INT_TO_TYPE_CODE[n] if (code) setExpandedCtCode(code) } }, [location.pathname, location.search]) const accordionValue = useMemo( () => ({ expandedCtCode, setExpandedCtCode, expandedPeCode, setExpandedPeCode }), [expandedCtCode, expandedPeCode], ) return (
) }