All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m51s
Bug: click leaf 'Duyet' (/purchase-evaluations?type=2&pendingMe=1) khien leaf 'Danh sach' (/purchase-evaluations?type=2) cung highlight cung luc. Nguyen nhan: NavLink 'end' prop chi match pathname. 2 leaf cung pathname /purchase-evaluations → ca 2 active. Fix: custom isActive voi queryMatches helper — compare query string dang key-value set (thu tu param khong quan trong). 2 leaf chi active khi pathname + query dung khop. Dong bo ca fe-admin + fe-user. Anh huong tat ca menu leaf co ?query= variants: Ct_* (Danh sach /contracts?type=N vs Duyet /contracts?type=N& pendingMe=1), Pe_* (tuong tu /purchase-evaluations), admin workflow leaf Wf_* + PeWf_* (khong dinh vi path khong query params).
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
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<string, LucideIcon>)[name]
|
|
return candidate ?? Circle
|
|
}
|
|
|
|
const TYPE_CODE_TO_INT: Record<string, number> = {
|
|
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<number, string> = Object.fromEntries(
|
|
Object.entries(TYPE_CODE_TO_INT).map(([k, v]) => [v, k]),
|
|
)
|
|
|
|
// Detect Ct_<Code> group key (no suffix, distinguish from leaf Ct_<Code>_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_<Code> 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<string, number> = { DuyetNcc: 1, DuyetNccPhuongAn: 2 }
|
|
const INT_TO_PE_CODE: Record<number, string> = 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<string, string> = {
|
|
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_<Code>_<Action> 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_<Code> (7 HĐ) + Pe_<Code> (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<AccordionContextValue>({
|
|
expandedCtCode: null,
|
|
setExpandedCtCode: () => {},
|
|
expandedPeCode: null,
|
|
setExpandedPeCode: () => {},
|
|
})
|
|
|
|
function MenuNodeRenderer({ node, depth = 0 }: { node: MenuNode; depth?: number }) {
|
|
const hasChildren = node.children.length > 0
|
|
if (hasChildren) return <MenuGroup node={node} depth={depth} />
|
|
return <MenuLeaf node={node} depth={depth} />
|
|
}
|
|
|
|
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_<Code> / Pe_<Code> 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 (
|
|
<div>
|
|
<button
|
|
onClick={toggle}
|
|
className={cn(
|
|
'flex w-full items-center justify-between rounded-md transition',
|
|
isTopLevel
|
|
? 'px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:bg-slate-100 whitespace-nowrap'
|
|
: 'px-3 py-1.5 text-[13px] font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
|
// Highlight Ct_ group đang active (accordion open) bằng tinted background
|
|
isAccordion && open && 'bg-slate-50 text-slate-900',
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Icon className="h-4 w-4" />
|
|
{node.label}
|
|
</span>
|
|
<ChevronDown className={cn('h-3.5 w-3.5 text-slate-400 transition', !open && '-rotate-90')} />
|
|
</button>
|
|
{open && (
|
|
<div className={cn(
|
|
'mt-0.5 space-y-0.5',
|
|
depth === 0 ? 'pl-2' : 'ml-3 mt-1 border-l border-slate-100 pl-3',
|
|
)}>
|
|
{node.children.map(c => (
|
|
<MenuNodeRenderer key={c.key} node={c} depth={depth + 1} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<NavLink
|
|
to={path}
|
|
className={cn(
|
|
'flex items-center gap-2.5 rounded-md transition',
|
|
isDeep ? 'px-3 py-1 text-[12px]' : 'px-3 py-2 text-sm font-medium',
|
|
isActive
|
|
? 'bg-brand-50 text-brand-700'
|
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
|
)}
|
|
>
|
|
<Icon className={cn(isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
|
{node.label}
|
|
</NavLink>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<NavLink
|
|
to={path}
|
|
end
|
|
className={({ isActive }) =>
|
|
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',
|
|
)
|
|
}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
{node.label}
|
|
</NavLink>
|
|
)
|
|
}
|
|
|
|
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<string | null>(null)
|
|
const [expandedPeCode, setExpandedPeCode] = useState<string | null>(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 (
|
|
<AccordionContext.Provider value={accordionValue}>
|
|
<div className="flex h-screen">
|
|
<aside className="flex w-72 flex-col border-r border-slate-200 bg-white">
|
|
<div className="flex h-16 items-center border-b border-slate-200 px-5">
|
|
<Link to="/dashboard" className="flex items-center gap-2.5">
|
|
<img src="/logo.png" alt="Solutions" className="h-8 w-auto" />
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">ERP</span>
|
|
</Link>
|
|
</div>
|
|
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
|
|
{USER_FIXED_TOP.map(n => <StaticLeaf key={n.key} node={n} />)}
|
|
{filteredMenu.map(n => (
|
|
<MenuNodeRenderer key={n.key} node={n} depth={0} />
|
|
))}
|
|
</nav>
|
|
</aside>
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<TopBar />
|
|
<main className="flex-1 overflow-auto">
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</AccordionContext.Provider>
|
|
)
|
|
}
|