[CLAUDE] FE: accordion mutex Pe_* + sidebar width w-72 + label nowrap
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m56s

2 fix user session 3:

1. Accordion mutex cho Pe_ groups (2 phieu Duyet NCC + Duyet NCC va
   Giai phap) — truoc chi Ct_ (7 HD) co mutex, Pe_ dung localOpen rieng
   → user click 1 Pe_ group xong click Pe_ khac → ca 2 expanded cung luc.
   Fix: extend AccordionContextValue + expandedPeCode + INT_TO_PE_CODE
   map. MenuGroup branch: isCtAccordion || isPeAccordion (families doc
   lap — user co the mo 1 Ct_ + 1 Pe_ cung luc). useEffect URL sync:
   /purchase-evaluations?type → Pe; /contracts* ?type → Ct.

2. Sidebar width w-64 (256px) → w-72 (288px) de label QUY TRINH CHON
   THAU PHU - NCC (30 char uppercase tracking-wide) fit single-line
   thay vi wrap 2 dong. Top-level group class:
     text-xs tracking-wider → text-[11px] tracking-wide + whitespace-nowrap
   Dong bo ca fe-admin + fe-user.
This commit is contained in:
pqhuy1987
2026-04-24 11:13:29 +07:00
parent 7783bd6005
commit 79398fb41f
2 changed files with 60 additions and 20 deletions

View File

@ -118,7 +118,7 @@ function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) {
className={cn(
'flex w-full items-center justify-between rounded-md transition',
isTopLevel
? 'px-3 py-2 text-xs font-semibold uppercase tracking-wider text-slate-500 hover:bg-slate-100'
? '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',
)}
>
@ -175,7 +175,7 @@ export function Layout() {
return (
<div className="flex h-screen">
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
<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" />

View File

@ -35,6 +35,21 @@ function getCtGroupCode(key: string): string | null {
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 {
@ -59,7 +74,6 @@ function resolvePath(key: string): string | null {
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending)$/)
if (peMatch) {
const [, code, action] = peMatch
const PE_CODE_TO_INT: Record<string, number> = { DuyetNcc: 1, DuyetNccPhuongAn: 2 }
const typeInt = PE_CODE_TO_INT[code]
if (!typeInt) return null
if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
@ -83,16 +97,21 @@ function filterForUser(nodes: MenuNode[]): MenuNode[] {
.map(n => ({ ...n, children: filterForUser(n.children) }))
}
// Accordion state cho 7 group Ct_<Code> — chỉ 1 expand cùng lúc. Auto-expand
// theo URL `?type=X` để khi navigate giữa các loại HĐ menu tự sync. Group
// non-Ct_ giữ behavior cũ (independent local useState).
// 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 }) {
@ -103,18 +122,28 @@ function MenuNodeRenderer({ node, depth = 0 }: { node: MenuNode; depth?: number
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> group: state controlled bởi accordion context
const isAccordion = ctCode != null
const open = isAccordion ? accordion.expandedCtCode === ctCode : localOpen
// 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 (isAccordion) {
if (isCtAccordion) {
accordion.setExpandedCtCode(open ? null : ctCode)
} else if (isPeAccordion) {
accordion.setExpandedPeCode(open ? null : peCode)
} else {
setLocalOpen(o => !o)
}
@ -130,7 +159,7 @@ function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) {
className={cn(
'flex w-full items-center justify-between rounded-md transition',
isTopLevel
? 'px-3 py-2 text-xs font-semibold uppercase tracking-wider text-slate-500 hover:bg-slate-100'
? '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',
@ -219,27 +248,38 @@ export function Layout() {
const filteredMenu = filterForUser(menu)
const location = useLocation()
// Accordion state — chỉ 1 Ct_<Code> group expand. Auto-sync theo URL ?type=
// (sidebar tự mở đúng loại HĐ user đang xem).
// 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) {
const code = INT_TO_TYPE_CODE[Number(typeParam)]
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)
}
// KHÔNG reset null khi URL không có ?type — giữ context user-selected
// (vd user đang xem dashboard nhưng đã click expand 1 group, giữ nguyên)
}, [location.search])
}, [location.pathname, location.search])
const accordionValue = useMemo(() => ({ expandedCtCode, setExpandedCtCode }), [expandedCtCode])
const accordionValue = useMemo(
() => ({ expandedCtCode, setExpandedCtCode, expandedPeCode, setExpandedPeCode }),
[expandedCtCode, expandedPeCode],
)
return (
<AccordionContext.Provider value={accordionValue}>
<div className="flex h-screen">
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
<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" />