[CLAUDE] Domain+Infra+App+FE-Admin: per-ContractType nested sidebar menu
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:
pqhuy1987
2026-04-21 22:25:00 +07:00
parent fb3a410a1b
commit 48e91fe7ca
7 changed files with 341 additions and 38 deletions

View File

@ -12,6 +12,7 @@ import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { FormsPage } from '@/pages/forms/FormsPage'
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
import { ReportsPage } from '@/pages/ReportsPage'
import { UsersPage } from '@/pages/system/UsersPage'
@ -36,6 +37,7 @@ function App() {
<Route path="/system/permissions" element={<PermissionsPage />} />
<Route path="/forms" element={<FormsPage />} />
<Route path="/contracts" element={<ContractsListPage />} />
<Route path="/contracts/new" element={<ContractCreatePage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />

View File

@ -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">

View File

@ -0,0 +1,160 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { Paged, Project, Supplier } from '@/types/master'
import type { ContractTemplate } from '@/types/forms'
import { ContractTypeLabel } from '@/types/forms'
export function ContractCreatePage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
// Pre-select type from sidebar menu link (?type=X). Fallback: Giao khoán.
const urlType = searchParams.get('type')
const initialType = urlType && !isNaN(Number(urlType)) ? Number(urlType) : 2
const [type, setType] = useState(initialType)
const [supplierId, setSupplierId] = useState('')
const [projectId, setProjectId] = useState('')
const [templateId, setTemplateId] = useState('')
const [giaTri, setGiaTri] = useState('')
const [tenHopDong, setTenHopDong] = useState('')
const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false)
const suppliers = useQuery({
queryKey: ['suppliers-all'],
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items,
})
const projects = useQuery({
queryKey: ['projects-all'],
queryFn: async () => (await api.get<Paged<Project>>('/projects', { params: { page: 1, pageSize: 200 } })).data.items,
})
const templates = useQuery({
queryKey: ['templates-by-type', type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
})
const create = useMutation({
mutationFn: async () => {
const res = await api.post<{ id: string }>('/contracts', {
type: Number(type),
supplierId,
projectId,
departmentId: null,
templateId: templateId || null,
giaTri: giaTri ? Number(giaTri) : 0,
tenHopDong: tenHopDong || null,
noiDung: noiDung || null,
bypassProcurementAndCCM: bypass,
draftData: null,
})
return res.data.id
},
onSuccess: id => {
toast.success('Đã tạo HĐ draft')
navigate(`/contracts/${id}`)
},
onError: err => toast.error(getErrorMessage(err)),
})
function submit(e: FormEvent) {
e.preventDefault()
if (!supplierId || !projectId) {
toast.error('Chọn NCC và dự án')
return
}
create.mutate()
}
const typeLabel = ContractTypeLabel[type] ?? 'HĐ'
return (
<div className="p-6">
<PageHeader
title={urlType ? `Tạo ${typeLabel}` : 'Tạo hợp đồng mới'}
description="Điền thông tin cơ bản. Sau đó bổ sung nội dung + submit lên phase góp ý."
/>
<form onSubmit={submit} className="max-w-3xl space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Loại *</Label>
<Select value={type} onChange={e => setType(Number(e.target.value))}>
{Object.entries(ContractTypeLabel).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Template (optional)</Label>
<Select value={templateId} onChange={e => setTemplateId(e.target.value)}>
<option value=""> Chưa chọn </option>
{templates.data?.filter(t => t.isActive).map(t => (
<option key={t.id} value={t.id}>{t.formCode} {t.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>NCC *</Label>
<Select value={supplierId} onChange={e => setSupplierId(e.target.value)} required>
<option value=""> Chọn </option>
{suppliers.data?.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Dự án *</Label>
<Select value={projectId} onChange={e => setProjectId(e.target.value)} required>
<option value=""> Chọn </option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Tên </Label>
<Input value={tenHopDong} onChange={e => setTenHopDong(e.target.value)} placeholder="vd: HĐ giao khoán nhân công dự án FLOCK 01" />
</div>
<div className="space-y-1.5">
<Label>Giá trị (VND)</Label>
<Input type="number" value={giaTri} onChange={e => setGiaTri(e.target.value)} />
</div>
<div className="flex items-end gap-2 pb-2">
<input
type="checkbox"
id="bypass"
checked={bypass}
onChange={e => setBypass(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
<Label htmlFor="bypass" className="cursor-pointer"> với Chủ đu (bypass CCM)</Label>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Nội dung / ghi chú</Label>
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} />
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>Hủy</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? 'Đang tạo…' : 'Tạo HĐ draft'}
</Button>
</div>
</form>
</div>
)
}

View File

@ -1,6 +1,6 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable'
import { PhaseBadge } from '@/components/PhaseBadge'
@ -16,20 +16,52 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function ContractsListPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
// URL-driven filters — sidebar menu links pass type + pendingMe via query.
// In-page filters (search, phase) are local state so admin can tweak freely.
const urlType = searchParams.get('type')
const urlPendingMe = searchParams.get('pendingMe') === '1'
const typeFilter = urlType ? Number(urlType) : null
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [phase, setPhase] = useState<string>('')
const list = useQuery({
queryKey: ['contracts', { page, search, phase }],
queryKey: ['contracts', { page, search, phase, typeFilter, urlPendingMe }],
queryFn: async () => {
const res = await api.get<Paged<ContractListItem>>('/contracts', {
params: { page, pageSize: 20, search: search || undefined, phase: phase || undefined },
params: {
page,
pageSize: 20,
search: search || undefined,
phase: phase || undefined,
},
})
return res.data
// BE doesn't filter by type in listContracts yet — do it client-side.
// Same for pendingMe; backend has /inbox but that returns different shape.
// This keeps the existing API surface small; can promote to BE later.
let items = res.data.items
if (typeFilter != null) items = items.filter(c => c.type === typeFilter)
return { ...res.data, items }
},
})
const headerTitle = useMemo(() => {
if (typeFilter != null) {
const label = ContractTypeLabel[typeFilter] ?? 'HĐ'
return urlPendingMe ? `${label} — Chờ duyệt` : label
}
return 'Hợp đồng'
}, [typeFilter, urlPendingMe])
const headerDesc = useMemo(() => {
if (urlPendingMe) return 'Các HĐ đang ở phase cần vai trò của bạn duyệt.'
if (typeFilter != null) return `Danh sách ${ContractTypeLabel[typeFilter] ?? 'HĐ'}.`
return 'Danh sách toàn bộ HĐ — filter theo phase, NCC, dự án.'
}, [typeFilter, urlPendingMe])
const columns: Column<ContractListItem>[] = [
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
{ key: 'tenHopDong', header: 'Tên HĐ', render: c => c.tenHopDong ?? '—' },
@ -43,7 +75,7 @@ export function ContractsListPage() {
return (
<div className="p-6">
<PageHeader title="Hợp đồng" description="Danh sách toàn bộ HĐ — filter theo phase, NCC, dự án." />
<PageHeader title={headerTitle} description={headerDesc} />
<div className="mb-3 flex gap-2">
<Input
@ -58,6 +90,14 @@ export function ContractsListPage() {
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
))}
</Select>
{typeFilter != null && (
<button
onClick={() => navigate('/contracts')}
className="rounded-md border border-slate-300 bg-white px-3 text-xs text-slate-600 hover:bg-slate-50"
>
Tất cả loại
</button>
)}
</div>
<DataTable

View File

@ -46,19 +46,31 @@ public class GetMyMenuTreeQueryHandler(
Update: g.Any(p => p.CanUpdate),
Delete: g.Any(p => p.CanDelete)));
// Build tree
List<MenuNodeDto> BuildChildren(string? parentKey) => menus
// Build tree. Children of `Contracts` (type submenu) inherit parent's
// permission so we don't need per-subitem permission rows. This keeps
// the permission matrix clean while allowing deep menu structures for
// pure navigation.
var (contractsRead, contractsCreate, contractsUpdate, contractsDelete) =
resolved.TryGetValue(MenuKeys.Contracts, out var cf) ? cf : (false, false, false, false);
List<MenuNodeDto> BuildChildren(string? parentKey, bool inheritContractsPerm) => menus
.Where(m => m.ParentKey == parentKey)
.Select(m =>
{
var flags = resolved.TryGetValue(m.Key, out var f) ? f : (false, false, false, false);
// If this node is a descendant of Contracts and has no explicit
// perms, take parent Contracts perms.
if (inheritContractsPerm && !resolved.ContainsKey(m.Key))
flags = (contractsRead, contractsCreate, contractsUpdate, contractsDelete);
var childInherits = inheritContractsPerm || m.Key == MenuKeys.Contracts;
return new MenuNodeDto(m.Key, m.Label, m.ParentKey, m.Order, m.Icon,
flags.Item1, flags.Item2, flags.Item3, flags.Item4,
BuildChildren(m.Key));
BuildChildren(m.Key, childInherits));
})
.ToList();
var tree = BuildChildren(null);
var tree = BuildChildren(null, inheritContractsPerm: false);
// Filter: chỉ trả về node có CanRead=true (hoặc có child CanRead=true)
static bool HasAccess(MenuNodeDto n) => n.CanRead || n.Children.Any(HasAccess);

View File

@ -17,6 +17,19 @@ public static class MenuKeys
public const string Roles = "Roles";
public const string Permissions = "Permissions";
// Per-contract-type menu groups + 3 action leaves each.
// Key format: Ct_<TypeCode>[_<Action>] — prefix `Ct_` distinguishes from
// top-level. Menu tree endpoint (GetMyMenuTreeQuery) treats descendants of
// `Contracts` as inheriting the parent permission so we don't need per-
// child permission rows for all roles.
public static readonly string[] ContractTypeCodes =
["ThauPhu", "GiaoKhoan", "NhaCungCap", "DichVu", "MuaBan", "NguyenTacNcc", "NguyenTacDv"];
public static string ContractTypeGroup(string typeCode) => $"Ct_{typeCode}";
public static string ContractTypeList(string typeCode) => $"Ct_{typeCode}_List";
public static string ContractTypeCreate(string typeCode) => $"Ct_{typeCode}_Create";
public static string ContractTypePending(string typeCode) => $"Ct_{typeCode}_Pending";
public static readonly string[] All =
[
Dashboard,

View File

@ -88,7 +88,18 @@ public static class DbInitializer
private static async Task SeedMenuTreeAsync(ApplicationDbContext db, ILogger logger)
{
var tree = new (string Key, string Label, string? Parent, int Order, string Icon)[]
var typeLabels = new Dictionary<string, string>
{
["ThauPhu"] = "HĐ Thầu phụ",
["GiaoKhoan"] = "HĐ Giao khoán",
["NhaCungCap"] = "HĐ Nhà cung cấp",
["DichVu"] = "HĐ Dịch vụ",
["MuaBan"] = "HĐ Mua bán",
["NguyenTacNcc"] = "HĐ Nguyên tắc NCC",
["NguyenTacDv"] = "HĐ Nguyên tắc Dịch vụ",
};
var tree = new List<(string Key, string Label, string? Parent, int Order, string Icon)>
{
(MenuKeys.Dashboard, "Tổng quan", null, 10, "LayoutDashboard"),
(MenuKeys.Master, "Danh mục", null, 20, "Database"),
@ -104,12 +115,24 @@ public static class DbInitializer
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
// (Danh sách / Thao tác / Duyệt).
var order = 31;
foreach (var code in MenuKeys.ContractTypeCodes)
{
var label = typeLabels.GetValueOrDefault(code, code);
tree.Add((MenuKeys.ContractTypeGroup(code), label, MenuKeys.Contracts, order++, "FileText"));
tree.Add((MenuKeys.ContractTypeList(code), "Danh sách", MenuKeys.ContractTypeGroup(code), order++, "List"));
tree.Add((MenuKeys.ContractTypeCreate(code), "Thao tác", MenuKeys.ContractTypeGroup(code), order++, "Plus"));
tree.Add((MenuKeys.ContractTypePending(code),"Duyệt", MenuKeys.ContractTypeGroup(code), order++, "CheckCircle2"));
}
var existingKeys = await db.MenuItems.Select(m => m.Key).ToListAsync();
var added = 0;
foreach (var (key, label, parent, order, icon) in tree)
foreach (var (key, label, parent, o, icon) in tree)
{
if (existingKeys.Contains(key)) continue;
db.MenuItems.Add(new MenuItem { Key = key, Label = label, ParentKey = parent, Order = order, Icon = icon });
db.MenuItems.Add(new MenuItem { Key = key, Label = label, ParentKey = parent, Order = o, Icon = icon });
added++;
}
if (added > 0)