[CLAUDE] Domain+Infra+App+FE-Admin: per-ContractType nested sidebar menu
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m48s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m48s
User request: mỗi loại HĐ có menu riêng với 3 action Danh sách / Thao tác / Duyệt. Sidebar giờ 3-level under "Hợp đồng": Hợp đồng (group, expandable) ├── HĐ Thầu phụ (sub-group) │ ├── Danh sách → /contracts?type=1 │ ├── Thao tác → /contracts/new?type=1 │ └── Duyệt → /contracts?type=1&pendingMe=1 ├── HĐ Giao khoán (sub-group) ├── HĐ NCC / Dịch vụ / Mua bán / Nguyên tắc NCC / Nguyên tắc DV └── ... (7 types × 4 = 28 new menu items) BE: - MenuKeys.cs: ContractTypeCodes array + helpers ContractTypeGroup/ List/Create/Pending → key format Ct_<TypeCode>[_<Action>] - DbInitializer.SeedMenuTreeAsync: loop seeds 28 entries under Contracts - GetMyMenuTreeQuery.BuildChildren: descendants of `Contracts` inherit parent permission (avoid adding 28 rows to Permissions table per role) FE: - Layout.tsx recursive: MenuNodeRenderer dispatches group vs leaf by depth; nested groups collapsed by default (top-level expanded). Deeper levels get smaller padding/text + left border guide. - Pattern-based resolvePath: Ct_<Type>_<Action> → URL with query. - Contract type code → int map (matches Domain ContractType enum). - ContractsListPage reads ?type + ?pendingMe, filters client-side. Header title + description reflect active filter. "← Tất cả loại" quick-reset button. - ContractCreatePage new cho admin (copy từ fe-user), pre-select type từ ?type URL param. - App.tsx route /contracts/new → ContractCreatePage. Pure navigation UX; no new permissions needed. Admin + any role with Contracts.Read see full menu; leaves click-through to filtered views. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -7,46 +7,89 @@ import { TopBar } from '@/components/TopBar'
|
||||
import type { MenuNode } from '@/types/menu'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// Map icon name → component (fallback Circle)
|
||||
function getIcon(name: string | null): LucideIcon {
|
||||
if (!name) return Circle
|
||||
const candidate = (Icons as unknown as Record<string, LucideIcon>)[name]
|
||||
return candidate ?? Circle
|
||||
}
|
||||
|
||||
// Map menu key → route path
|
||||
const KEY_TO_PATH: Record<string, string> = {
|
||||
Dashboard: '/dashboard',
|
||||
Suppliers: '/master/suppliers',
|
||||
Projects: '/master/projects',
|
||||
Departments: '/master/departments',
|
||||
Contracts: '/contracts',
|
||||
Forms: '/forms',
|
||||
Reports: '/reports',
|
||||
Users: '/system/users',
|
||||
Roles: '/system/roles',
|
||||
Permissions: '/system/permissions',
|
||||
// Map contract type code → ContractType int enum value (mirrors
|
||||
// Domain/Contracts/ContractType.cs for URL filter).
|
||||
const TYPE_CODE_TO_INT: Record<string, number> = {
|
||||
ThauPhu: 1,
|
||||
GiaoKhoan: 2,
|
||||
NhaCungCap: 3,
|
||||
DichVu: 4,
|
||||
MuaBan: 5,
|
||||
NguyenTacNcc: 6,
|
||||
NguyenTacDv: 7,
|
||||
}
|
||||
|
||||
function MenuGroup({ node }: { node: MenuNode }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
// Resolve menu key → route. Static map for top-level items, pattern for
|
||||
// Ct_<Type>_<Action> sub-menu entries.
|
||||
function resolvePath(key: string): string | null {
|
||||
const staticMap: Record<string, string> = {
|
||||
Dashboard: '/dashboard',
|
||||
Suppliers: '/master/suppliers',
|
||||
Projects: '/master/projects',
|
||||
Departments: '/master/departments',
|
||||
Contracts: '/contracts',
|
||||
Forms: '/forms',
|
||||
Reports: '/reports',
|
||||
Users: '/system/users',
|
||||
Roles: '/system/roles',
|
||||
Permissions: '/system/permissions',
|
||||
}
|
||||
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 `/contracts?type=${typeInt}`
|
||||
if (action === 'Create') return `/contracts/new?type=${typeInt}`
|
||||
if (action === 'Pending') return `/contracts?type=${typeInt}&pendingMe=1`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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 }) {
|
||||
// Top-level groups expanded by default, nested ones collapsed to reduce noise
|
||||
const [open, setOpen] = useState(depth === 0)
|
||||
const Icon = getIcon(node.icon)
|
||||
const isTopLevel = depth === 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-xs font-semibold uppercase text-slate-500 hover:bg-slate-100"
|
||||
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-1.5 text-[13px] font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{node.label}
|
||||
</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition', !open && '-rotate-90')} />
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 text-slate-400 transition', !open && '-rotate-90')} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-1 space-y-0.5 pl-2">
|
||||
<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 => (
|
||||
<MenuLeaf key={c.key} node={c} />
|
||||
<MenuNodeRenderer key={c.key} node={c} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@ -54,21 +97,29 @@ function MenuGroup({ node }: { node: MenuNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MenuLeaf({ node }: { node: MenuNode }) {
|
||||
function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) {
|
||||
const Icon = getIcon(node.icon)
|
||||
const path = KEY_TO_PATH[node.key]
|
||||
const path = resolvePath(node.key)
|
||||
if (!path) return null
|
||||
const isDeep = depth >= 2
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={path}
|
||||
// NavLink's default "startsWith" match causes /contracts?type=1 and
|
||||
// /contracts to both highlight. Use `end` for query-param variants.
|
||||
end={path.includes('?')}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 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',
|
||||
'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="h-4 w-4" />
|
||||
<Icon className={cn(isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
||||
{node.label}
|
||||
</NavLink>
|
||||
)
|
||||
@ -87,7 +138,9 @@ export function Layout() {
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
|
||||
{menu.map(n => (n.children.length > 0 ? <MenuGroup key={n.key} node={n} /> : <MenuLeaf key={n.key} node={n} />))}
|
||||
{menu.map(n => (
|
||||
<MenuNodeRenderer key={n.key} node={n} depth={0} />
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
||||
Reference in New Issue
Block a user