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 = { // [W2 KHKK S161] GĐ2 hết skeleton — 6 leaf re-point trang thật (gotcha #50: // thiếu route = sidebar drop SILENT; filter đi qua query param, page map FILTER_TO_PHASE). Khkk_List: '/khkk/list', Khkk_Create: '/khkk/create', Khkk_Pending: '/khkk/list?filter=ChoDuyet', Khkk_Approved: '/khkk/list?filter=DaDuyet', Hdc_ThauPhu: '/coming-soon?stage=4&t=01', Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02', Hdc_NhaCungCap: '/coming-soon?stage=4&t=03', Hdc_DichVu: '/coming-soon?stage=4&t=04', Hdc_MuaBan: '/coming-soon?stage=4&t=05', Hdc_NguyenTacNcc: '/coming-soon?stage=4&t=06', Hdc_NguyenTacDv: '/coming-soon?stage=4&t=07', Dashboard: '/dashboard', Contracts: '/my-contracts', PurchaseEvaluations: '/purchase-evaluations', // [S159 2026-07-29] 2 root placeholder giai đoạn 2/4 (owner: menu hiển thị đúng 4 GĐ // như tab strip). Module chưa build → trỏ về Tổng quan pipeline (khung "Sắp triển khai"). // LESSON gotcha #50: key DB mới PHẢI có route ở đây, không thì sidebar drop SILENT. KeHoachKyKet: '/khkk/list', // [W2 S161] root GĐ2 hết placeholder — về danh sách phiếu HopDongCung: '/dashboard', // [W2 S161] 2 leaf còn lại — WfView đi alias matrix type=10 (App.tsx, không đẻ page mới) Khkk_WfView: '/khkk/workflow-matrix?type=10', Khkk_Deleted: '/khkk/list?view=deleted', // [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', // [P11-C S51] danh mục xe công + tài xế — cùng page :kind-driven. Hrm_Config_Vehicles: '/hrm/configs/vehicles', Hrm_Config_Drivers: '/hrm/configs/drivers', // [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_Dashboard: '/office/dashboard', 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', // Phase 10.3-10.4 S38 — Workflow Apps skeleton (8 entries) Off_DonTu_Leave: '/workflow-apps/leave', Off_DonTu_Ot: '/workflow-apps/ot', Off_DonTu_Travel: '/workflow-apps/travel', Off_DatXe: '/workflow-apps/vehicle', Off_ItTicket: '/it-tickets', Off_ChamCong: '/attendance', Hrm_Dashboard: '/hr/dashboard', } if (staticMap[key]) return staticMap[key] // [S159-đợt5 owner "y chang Duyệt NCC"] +WfView/Approved/Deleted. WfView cả 7 loại // đều ?type=3 (HĐ chung 1 bộ workflow V2) — thêm &ct=N để active-state riêng từng leaf. const match = key.match(/^Ct_([^_]+)_(List|Create|Pending|WfView|Approved|Deleted)$/) 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}` if (action === 'WfView') return `/contracts/workflow-matrix?type=3&ct=${typeInt}` if (action === 'Approved') return `/my-contracts?type=${typeInt}&phase=9` if (action === 'Deleted') return `/my-contracts?type=${typeInt}&deleted=1` } // Pe__ cho module Duyệt NCC (user side) // [S155 đợt 2 — owner restructure menu 2026-07-27] +`Approved` +`Deleted`. Đây là // "chỗ thứ 4" của Pattern 16-bis (staticMap/resolvePath) — key seed ở BE mà quên nới // regex thì `resolvePath` trả null ⇒ MenuLeaf DROP IM LẶNG khỏi sidebar (gotcha #50). // 🔴 `WfView` GIỮ NGUYÊN chỉ ở fe-user (lead chốt đường (c)): fe-admin KHÔNG có // `WorkflowMatrixViewPage` nên thêm `WfView` bên đó sẽ đẻ link chết. const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending|WfView|Approved|Deleted)$/) 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` // [S155 đợt 2] 2 mục MỚI — CẢ HAI tái dùng `PurchaseEvaluationsListPage`, 0 route mới, // 0 page mới. "Đã duyệt" chỉ là filter phase sẵn có (`DaDuyet = 7`, đã thông 3 tầng); // "Đã xóa" là view riêng trong cùng page, gọi endpoint `/purchase-evaluations/deleted`. // 🔴 `view=approved` là KHOÁ ĐỊNH DANH, KHÔNG phải bộ lọc — trang bỏ qua nó, chỉ // `phase=7` mới thật sự lọc. Vì sao phải có: URL của mục "Đã duyệt" TRÙNG HỆT URL // mà user tự lọc trạng thái "Đã duyệt" trên trang Danh sách ⇒ không phân biệt được // bằng URL ⇒ menu sáng nhầm (bug UAT anh báo 2026-07-27). `phase` nằm trong // TRANSIENT (là bộ lọc) nên KHÔNG dùng làm danh tính được; `view` thì không. if (action === 'Approved') return `/purchase-evaluations?type=${typeInt}&phase=7&view=approved` if (action === 'Deleted') return `/purchase-evaluations?type=${typeInt}&deleted=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) // 🔴 [S155 đợt 2] Transient chỉ được bỏ qua khi MENU ĐÍCH KHÔNG tự ghim key đó. // Bug UAT 2026-07-27 (anh báo): click "Danh sách" mà menu sáng "Đã duyệt". // Nguyên nhân: `phase` vừa là BỘ LỌC trạng thái trên trang danh sách (nên nằm // trong TRANSIENT — fix 2026-05-08), vừa vừa-mới trở thành DANH TÍNH điều hướng // của mục "Đã duyệt" (`?type=N&phase=7`, đợt 2). Strip cả 2 vế ⇒ "Danh sách" // (`[type]`) và "Đã duyệt" (`[type]` sau khi mất `phase`) thành KHÔNG PHÂN BIỆT // ĐƯỢC ⇒ URL `?type=1` khớp cả hai, mục render sau thắng. // Cùng một tham số không thể vừa là thứ-bị-bỏ-qua vừa là thứ-định-danh; nên // quyền quyết định thuộc về ĐÍCH: đích ghim `phase` ⇒ so khớp NGHIÊM; đích không // ghim ⇒ giữ nguyên hành vi cũ (lọc/chọn dòng không làm mất highlight). const pinnedByTarget = new Set(b.keys()) const ignorable = (k: string) => TRANSIENT_QUERY_KEYS.has(k) && !pinnedByTarget.has(k) const aKeys = [...a.keys()].filter(k => !ignorable(k)).sort() const bKeys = [...b.keys()].filter(k => !ignorable(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( 'relative block rounded-md px-3 py-1.5 text-[12px] font-medium leading-snug transition-colors', isActive ? 'bg-brand-50 font-semibold text-brand-700 before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full before:bg-brand-600' : '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 ( {/* Brand stripe — dải nhận diện #1F7DC1 chạy suốt đỉnh app. 2026-06-16 anh "nâng màu": dày hơn (h-1.5) + dải gradient xanh đậm bắt mắt. */}
) }