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', } 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)$/) if (peMatch) { const [, code, action] = peMatch const typeInt = PE_CODE_TO_INT[code] if (!typeInt) return null if (action === 'List') return `/purchase-evaluations?type=${typeInt}` if (action === 'Create') return `/purchase-evaluations/new?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. const USER_HIDDEN_KEYS = new Set([ 'Master', 'Suppliers', 'Projects', 'Departments', 'System', 'Users', 'Roles', 'Permissions', 'Forms', 'Reports', ]) function filterForUser(nodes: MenuNode[]): MenuNode[] { return nodes .filter(n => !USER_HIDDEN_KEYS.has(n.key)) .map(n => ({ ...n, children: filterForUser(n.children) })) } // 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 => ( ))}
)}
) } // So sánh 2 query string dạng key-value set (thứ tự param không quan trọng). // Dùng để distinguish /path?type=2 vs /path?type=2&pendingMe=1 — NavLink isActive // built-in chỉ match pathname, không check query string. function queryMatches(current: string, target: string): boolean { const a = new URLSearchParams(current) const b = new URLSearchParams(target) const aKeys = [...a.keys()].sort() const bKeys = [...b.keys()].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 ( {node.label} ) } // 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, 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( 'flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition', isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900', ) } > {node.label} ) } 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 (
) }