[CLAUDE] FE-User: sidebar accordion cho menu loại HĐ — chỉ 1 group expand cùng lúc
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m46s

User feedback: 7 group Ct_<Code> (HĐ Thầu phụ / Giao khoán / NCC / Dịch vụ
/ Mua bán / Nguyên tắc NCC / Nguyên tắc DV) trước đây expand tự do →
sidebar dài lê thê khi user mở nhiều. Mỗi group nên độc lập (accordion):
chỉ 1 group expand cùng lúc.

## Cách làm

### AccordionContext lifted to Layout

- Layout maintain `expandedCtCode: string | null` state
- React Context expose getter + setter cho MenuGroup
- MenuGroup detect key `Ct_<Code>` qua regex `/^Ct_([^_]+)$/`:
  - Match → controlled mode: open = (expandedCtCode === code)
  - Toggle = setExpandedCtCode(open ? null : code)
- Group khác (Hợp đồng top-level, Quy trình admin, ...) giữ behavior cũ
  (independent local useState)

### Auto-expand theo URL ?type=

useEffect watch location.search:
- `/my-contracts?type=5` → INT_TO_TYPE_CODE[5] = "MuaBan" → expand HĐ Mua bán
- `/contracts/new?type=2` → expand HĐ Giao khoán
- `/inbox?type=3` → expand HĐ Nhà cung cấp
- URL không có ?type= → KHÔNG reset (giữ user-selected context)

### Visual: highlight active group

Ct_ group đang accordion-open: `bg-slate-50 text-slate-900` (subtle tint
để user biết group nào đang active trong 7 type).

## Build

fe-user: tsc -b + vite build pass (1888 modules, 1.08MB JS, 380ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 09:33:56 +07:00
parent 89c7e88e2d
commit 7ea3957acc

View File

@ -1,7 +1,7 @@
import { Link, NavLink, Outlet } from 'react-router-dom' 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 { ChevronDown, Circle, type LucideIcon } from 'lucide-react'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { useState } from 'react'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { TopBar } from '@/components/TopBar' import { TopBar } from '@/components/TopBar'
import type { MenuNode } from '@/types/menu' import type { MenuNode } from '@/types/menu'
@ -23,6 +23,18 @@ const TYPE_CODE_TO_INT: Record<string, number> = {
NguyenTacDv: 7, 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
}
// User-side menu key → route. Differs from admin: Danh sách points to // 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). // /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
function resolvePath(key: string): string | null { function resolvePath(key: string): string | null {
@ -58,6 +70,18 @@ function filterForUser(nodes: MenuNode[]): MenuNode[] {
.map(n => ({ ...n, children: filterForUser(n.children) })) .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).
type AccordionContextValue = {
expandedCtCode: string | null
setExpandedCtCode: (code: string | null) => void
}
const AccordionContext = createContext<AccordionContextValue>({
expandedCtCode: null,
setExpandedCtCode: () => {},
})
function MenuNodeRenderer({ node, depth = 0 }: { node: MenuNode; depth?: number }) { function MenuNodeRenderer({ node, depth = 0 }: { node: MenuNode; depth?: number }) {
const hasChildren = node.children.length > 0 const hasChildren = node.children.length > 0
if (hasChildren) return <MenuGroup node={node} depth={depth} /> if (hasChildren) return <MenuGroup node={node} depth={depth} />
@ -65,19 +89,38 @@ function MenuNodeRenderer({ node, depth = 0 }: { node: MenuNode; depth?: number
} }
function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) { function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) {
const [open, setOpen] = useState(depth === 0) const ctCode = getCtGroupCode(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
function toggle() {
if (isAccordion) {
accordion.setExpandedCtCode(open ? null : ctCode)
} else {
setLocalOpen(o => !o)
}
}
const Icon = getIcon(node.icon) const Icon = getIcon(node.icon)
const isTopLevel = depth === 0 const isTopLevel = depth === 0
return ( return (
<div> <div>
<button <button
onClick={() => setOpen(o => !o)} onClick={toggle}
className={cn( className={cn(
'flex w-full items-center justify-between rounded-md transition', 'flex w-full items-center justify-between rounded-md transition',
isTopLevel isTopLevel
? 'px-3 py-2 text-xs font-semibold uppercase tracking-wider text-slate-500 hover:bg-slate-100' ? 'px-3 py-2 text-xs font-semibold uppercase tracking-wider text-slate-500 hover:bg-slate-100'
: 'px-3 py-1.5 text-[13px] font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900', : '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"> <span className="flex items-center gap-2">
@ -161,29 +204,49 @@ function StaticLeaf({ node }: { node: MenuNode }) {
export function Layout() { export function Layout() {
const { menu } = useAuth() const { menu } = useAuth()
const filteredMenu = filterForUser(menu) 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).
const [expandedCtCode, setExpandedCtCode] = 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 (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])
const accordionValue = useMemo(() => ({ expandedCtCode, setExpandedCtCode }), [expandedCtCode])
return ( return (
<div className="flex h-screen"> <AccordionContext.Provider value={accordionValue}>
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white"> <div className="flex h-screen">
<div className="flex h-16 items-center border-b border-slate-200 px-5"> <aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
<Link to="/inbox" className="flex items-center gap-2.5"> <div className="flex h-16 items-center border-b border-slate-200 px-5">
<img src="/logo.png" alt="Solutions" className="h-8 w-auto" /> <Link to="/inbox" className="flex items-center gap-2.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">ERP</span> <img src="/logo.png" alt="Solutions" className="h-8 w-auto" />
</Link> <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>
<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>
</div> </AccordionContext.Provider>
) )
} }