[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

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