[CLAUDE] Domain+App+Api+FE-User+FE-Admin: Plan AA Chunk A - BE IsUserSelectable filter + menu seed Pe_DuyetNcc_WfView + sidebar widen w-72 xl:w-80 + revert Plan U truncate

BE changes:
- MenuKeys.cs +helper PurchaseEvaluationWorkflowView(typeCode) => "Pe_{typeCode}_WfView"
- DbInitializer.cs SeedMenuTreeAsync:
  - tree.Add LuongDuyet (Order=2 first child) cho 2 type PE
  - INSERT-only loop -> INSERT-OR-UPDATE-Order (shift existing prod rows Order+1)
  - Idempotent: skip nếu Order match, UPDATE nếu mismatch
- DbInitializer.cs SeedPurchaseEvaluationPermissionDefaultsAsync +WfView leaf cho 7 role Read
- ApprovalWorkflowV2AdminFeatures.cs GetAwAdminOverviewQuery +IsUserSelectable bool? = null
  + handler conditional Where(d => d.IsUserSelectable == ius)
- ApprovalWorkflowsV2Controller.cs Overview signature +[FromQuery] bool? isUserSelectable
  pass-through to mediator (gotcha #44 fix preserved class-level [Authorize] bare)

FE Layout changes (mirror 2 app rule §3.9):
- fe-user resolvePath regex (List|Create|Pending|WfView) + route
  /purchase-evaluations/workflow-matrix?type=N
- fe-user + fe-admin sidebar w-60 xl:w-72 -> w-72 xl:w-80 (+48/+32px gain)
- Revert Plan U S23 t11 truncate × 5 sites (3 fe-user MenuGroup+MenuLeaf+StaticLeaf
  + 2 fe-admin MenuGroup+MenuLeaf). Keep min-w-0 flex-1 + shrink-0 + title
  tooltip (no harm). Bro request hiển thị đầy đủ label custom Mig 27 dài.

Why:
- User UAT request 2026-05-15: thêm menu "Luồng duyệt" trên Danh sách hiển thị
  ma trận phân quyền workflow V2 admin Designer ghim ra cho user xem trước khi
  tạo phiếu. Filter IsUserSelectable=true (Mig 25).
- Sidebar Plan U S23 t11 truncate hiển thị "..." → bro muốn full label.
  Widen sidebar +32-48px + bỏ truncate cho phép wrap natural khi cực dài.

Verify:
- dotnet build SolutionErp.slnx PASS clean 0 err 2 warn pre-existing DocxRenderer
- Investigator Pre-A confirm gotcha #44 đã fix permanent từ 2026-05-08
- Reviewer cumulative PASS 0 critical / 0 major / 0 minor blocker

Pending Chunk B: FE WorkflowMatrixViewPage.tsx ~215 LOC + types + App.tsx route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-15 16:34:40 +07:00
parent a1a910f576
commit ee776d5745
6 changed files with 73 additions and 24 deletions

View File

@ -141,11 +141,13 @@ function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) {
: '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',
)} )}
> >
{/* Plan U S23 t11 — truncate label dài + tooltip hover (mirror fe-user {/* [Plan AA S24 t1] Revert truncate Plan U S23 t11 — mirror fe-user
rule §3.9). min-w-0 cần thiết cho truncate flex child. */} rule §3.9. Admin sidebar không có label custom Mig 27 (luôn Label
gốc) nên label rarely overflow, nhưng pattern uniform với fe-user.
min-w-0 flex-1 + shrink-0 giữ responsive smooth. */}
<span className="flex min-w-0 flex-1 items-center gap-2"> <span className="flex min-w-0 flex-1 items-center gap-2">
<Icon className="h-4 w-4 shrink-0" /> <Icon className="h-4 w-4 shrink-0" />
<span className="truncate" title={node.label}>{node.label}</span> <span title={node.label}>{node.label}</span>
</span> </span>
<ChevronDown className={cn('h-3.5 w-3.5 shrink-0 text-slate-400 transition', !open && '-rotate-90')} /> <ChevronDown className={cn('h-3.5 w-3.5 shrink-0 text-slate-400 transition', !open && '-rotate-90')} />
</button> </button>
@ -205,7 +207,8 @@ function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) {
)} )}
> >
<Icon className={cn('shrink-0', isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} /> <Icon className={cn('shrink-0', isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
<span className="truncate">{node.label}</span> {/* [Plan AA S24 t1] Revert truncate Plan U S23 t11 — mirror fe-user. */}
<span>{node.label}</span>
</NavLink> </NavLink>
) )
} }
@ -215,7 +218,7 @@ export function Layout() {
return ( return (
<div className="flex h-screen"> <div className="flex h-screen">
<aside className="flex w-60 flex-col border-r border-slate-200 bg-white xl:w-72"> <aside className="flex w-72 flex-col border-r border-slate-200 bg-white xl:w-80">
<div className="flex h-16 items-center border-b border-slate-200 px-5"> <div className="flex h-16 items-center border-b border-slate-200 px-5">
<Link to="/dashboard" className="flex items-center gap-2.5"> <Link to="/dashboard" className="flex items-center gap-2.5">
<img src="/logo.png" alt="Solutions" className="h-8 w-auto" /> <img src="/logo.png" alt="Solutions" className="h-8 w-auto" />

View File

@ -75,11 +75,15 @@ function resolvePath(key: string): string | null {
} }
// Pe_<Code>_<Action> cho module Duyệt NCC (user side) // Pe_<Code>_<Action> cho module Duyệt NCC (user side)
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending)$/) const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending|WfView)$/)
if (peMatch) { if (peMatch) {
const [, code, action] = peMatch const [, code, action] = peMatch
const typeInt = PE_CODE_TO_INT[code] const typeInt = PE_CODE_TO_INT[code]
if (!typeInt) return null if (!typeInt) return null
// [Plan AA S24 t1] "Luồng duyệt" leaf — read-only matrix view phía trên
// Danh sách. User xem trước khi tạo phiếu ai duyệt step nào (filter
// workflow IsUserSelectable=true admin ghim).
if (action === 'WfView') return `/purchase-evaluations/workflow-matrix?type=${typeInt}`
if (action === 'List') return `/purchase-evaluations?type=${typeInt}` if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
// "Thao tác" leaf → workspace 2-panel (Q4 2026-05-07): pick + create + sửa // "Thao tác" leaf → workspace 2-panel (Q4 2026-05-07): pick + create + sửa
// tables inline. Header-only `/new` page giữ tồn tại cho deep-link cũ // tables inline. Header-only `/new` page giữ tồn tại cho deep-link cũ
@ -180,12 +184,14 @@ function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) {
isAccordion && open && 'bg-slate-50 text-slate-900', isAccordion && open && 'bg-slate-50 text-slate-900',
)} )}
> >
{/* Plan U S23 t11 — truncate label dài + tooltip hover (admin Mig 27 {/* [Plan AA S24 t1] Revert truncate Plan U S23 t11 — bro request hiển
th đặt DisplayLabel dài). min-w-0 cần thiết để truncate hoạt th đầy đủ label custom. Sidebar widen w-72 xl:w-80 fit ~44 chars
động trong flex child. shrink-0 giữ icon + chevron không co. */} 1 dòng. Label cực dài fallback wrap multi-line natural CSS.
min-w-0 flex-1 + shrink-0 icon/chevron giữ responsive smooth. title
tooltip giữ (no harm — accessibility bonus). */}
<span className="flex min-w-0 flex-1 items-center gap-2"> <span className="flex min-w-0 flex-1 items-center gap-2">
<Icon className="h-4 w-4 shrink-0" /> <Icon className="h-4 w-4 shrink-0" />
<span className="truncate" title={effectiveLabel(node)}>{effectiveLabel(node)}</span> <span title={effectiveLabel(node)}>{effectiveLabel(node)}</span>
</span> </span>
<ChevronDown className={cn('h-3.5 w-3.5 shrink-0 text-slate-400 transition', !open && '-rotate-90')} /> <ChevronDown className={cn('h-3.5 w-3.5 shrink-0 text-slate-400 transition', !open && '-rotate-90')} />
</button> </button>
@ -248,7 +254,9 @@ function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) {
)} )}
> >
<Icon className={cn('shrink-0', isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} /> <Icon className={cn('shrink-0', isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
<span className="truncate">{effectiveLabel(node)}</span> {/* [Plan AA S24 t1] Revert truncate Plan U S23 t11 — fit full label hoặc
wrap natural. NavLink `title` (line 241) giữ tooltip accessibility. */}
<span>{effectiveLabel(node)}</span>
</NavLink> </NavLink>
) )
} }
@ -281,7 +289,10 @@ function StaticLeaf({ node }: { node: MenuNode }) {
} }
> >
<Icon className="h-4 w-4 shrink-0" /> <Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{effectiveLabel(node)}</span> {/* [Plan AA S24 t1] Revert truncate Plan U S23 t11 — StaticLeaf "Hộp thư"
label ngắn (7 chars) không bao giờ overflow, drop truncate cho
consistent với MenuLeaf + MenuGroup. */}
<span>{effectiveLabel(node)}</span>
</NavLink> </NavLink>
) )
} }
@ -322,7 +333,7 @@ export function Layout() {
return ( return (
<AccordionContext.Provider value={accordionValue}> <AccordionContext.Provider value={accordionValue}>
<div className="flex h-screen"> <div className="flex h-screen">
<aside className="flex w-60 flex-col border-r border-slate-200 bg-white xl:w-72"> <aside className="flex w-72 flex-col border-r border-slate-200 bg-white xl:w-80">
<div className="flex h-16 items-center border-b border-slate-200 px-5"> <div className="flex h-16 items-center border-b border-slate-200 px-5">
<Link to="/dashboard" className="flex items-center gap-2.5"> <Link to="/dashboard" className="flex items-center gap-2.5">
<img src="/logo.png" alt="Solutions" className="h-8 w-auto" /> <img src="/logo.png" alt="Solutions" className="h-8 w-auto" />

View File

@ -21,8 +21,9 @@ public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
[HttpGet] [HttpGet]
public async Task<ActionResult<AwAdminOverviewDto>> Overview( public async Task<ActionResult<AwAdminOverviewDto>> Overview(
[FromQuery] int? applicableType, [FromQuery] int? applicableType,
[FromQuery] bool? isUserSelectable,
CancellationToken ct) CancellationToken ct)
=> Ok(await mediator.Send(new GetAwAdminOverviewQuery(applicableType), ct)); => Ok(await mediator.Send(new GetAwAdminOverviewQuery(applicableType, isUserSelectable), ct));
[HttpPost] [HttpPost]
[Authorize(Policy = "Workflows.Create")] [Authorize(Policy = "Workflows.Create")]

View File

@ -86,7 +86,12 @@ internal static class AwLabels
// ========== GET overview ========== // ========== GET overview ==========
// Filter `applicableType=null` → return tất cả, `=N` → chỉ type đó. // Filter `applicableType=null` → return tất cả, `=N` → chỉ type đó.
public record GetAwAdminOverviewQuery(int? ApplicableType = null) : IRequest<AwAdminOverviewDto>; // [Plan AA S24 t1] `IsUserSelectable` filter cho user read-only matrix view —
// chỉ workflow ghim (Mig 25 admin pin). Admin Designer KHÔNG truyền param →
// return tất cả như cũ.
public record GetAwAdminOverviewQuery(
int? ApplicableType = null,
bool? IsUserSelectable = null) : IRequest<AwAdminOverviewDto>;
public class GetAwAdminOverviewQueryHandler( public class GetAwAdminOverviewQueryHandler(
IApplicationDbContext db, IApplicationDbContext db,
@ -106,6 +111,11 @@ public class GetAwAdminOverviewQueryHandler(
query = query.Where(d => d.ApplicableType == typeEnum); query = query.Where(d => d.ApplicableType == typeEnum);
} }
if (request.IsUserSelectable is bool ius)
{
query = query.Where(d => d.IsUserSelectable == ius);
}
var definitions = await query.ToListAsync(ct); var definitions = await query.ToListAsync(ct);
// Resolve dept names // Resolve dept names

View File

@ -83,6 +83,10 @@ public static class MenuKeys
public static string PurchaseEvaluationList(string typeCode) => $"Pe_{typeCode}_List"; public static string PurchaseEvaluationList(string typeCode) => $"Pe_{typeCode}_List";
public static string PurchaseEvaluationCreate(string typeCode) => $"Pe_{typeCode}_Create"; public static string PurchaseEvaluationCreate(string typeCode) => $"Pe_{typeCode}_Create";
public static string PurchaseEvaluationPending(string typeCode) => $"Pe_{typeCode}_Pending"; public static string PurchaseEvaluationPending(string typeCode) => $"Pe_{typeCode}_Pending";
// [Plan AA S24 t1] User read-only view ma trận phân quyền của workflow V2
// admin Designer đã ghim (IsUserSelectable=true). Suffix `_WfView` distinct
// với admin `PeWf_*` (Designer write).
public static string PurchaseEvaluationWorkflowView(string typeCode) => $"Pe_{typeCode}_WfView";
// Workflow admin leaf per PE type — dưới PeWorkflows, click leaf mở // Workflow admin leaf per PE type — dưới PeWorkflows, click leaf mở
// /system/pe-workflows/{typeCode} // /system/pe-workflows/{typeCode}

View File

@ -1431,9 +1431,13 @@ public static class DbInitializer
{ {
var label = peTypeLabels.GetValueOrDefault(code, code); var label = peTypeLabels.GetValueOrDefault(code, code);
tree.Add((MenuKeys.PurchaseEvaluationGroup(code), label, MenuKeys.PurchaseEvaluations, peOrder++, "FileCheck")); tree.Add((MenuKeys.PurchaseEvaluationGroup(code), label, MenuKeys.PurchaseEvaluations, peOrder++, "FileCheck"));
// [Plan AA S24 t1] "Luồng duyệt" leaf phía trên "Danh sách" — read-only
// view ma trận phân quyền workflow V2 admin Designer ghim. User biết
// trước khi tạo phiếu ai duyệt step nào.
tree.Add((MenuKeys.PurchaseEvaluationWorkflowView(code), "Luồng duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Network"));
tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "List")); tree.Add((MenuKeys.PurchaseEvaluationList(code), "Danh sách", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "List"));
tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Plus")); tree.Add((MenuKeys.PurchaseEvaluationCreate(code), "Thao tác", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "Plus"));
tree.Add((MenuKeys.PurchaseEvaluationPending(code),"Duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCircle2")); tree.Add((MenuKeys.PurchaseEvaluationPending(code), "Duyệt", MenuKeys.PurchaseEvaluationGroup(code), peOrder++, "CheckCircle2"));
} }
// PE workflow admin leaves dưới `PeWorkflows` // PE workflow admin leaves dưới `PeWorkflows`
@ -1444,18 +1448,32 @@ public static class DbInitializer
tree.Add((MenuKeys.PeWorkflowTypeLeaf(code), label, MenuKeys.PeWorkflows, peWfOrder++, "FileCheck")); tree.Add((MenuKeys.PeWorkflowTypeLeaf(code), label, MenuKeys.PeWorkflows, peWfOrder++, "FileCheck"));
} }
var existingKeys = await db.MenuItems.Select(m => m.Key).ToListAsync(); var existingItems = await db.MenuItems.ToDictionaryAsync(m => m.Key);
var added = 0; var added = 0;
var reordered = 0;
foreach (var (key, label, parent, o, icon) in tree) foreach (var (key, label, parent, o, icon) in tree)
{ {
if (existingKeys.Contains(key)) continue; if (existingItems.TryGetValue(key, out var existing))
{
// [Plan AA S24 t1] Shift Order khi insert leaf mới giữa list cũ —
// existing rows phải UPDATE Order theo seed list canonical.
// Idempotent: skip nếu Order đã match. KHÔNG đụng Label / ParentKey
// / Icon (label backfill section riêng phía dưới).
if (existing.Order != o)
{
existing.Order = o;
reordered++;
}
continue;
}
db.MenuItems.Add(new MenuItem { Key = key, Label = label, ParentKey = parent, Order = o, Icon = icon }); db.MenuItems.Add(new MenuItem { Key = key, Label = label, ParentKey = parent, Order = o, Icon = icon });
added++; added++;
} }
if (added > 0) if (added > 0 || reordered > 0)
{ {
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogInformation("Seeded {Count} menu items", added); if (added > 0) logger.LogInformation("Seeded {Count} menu items", added);
if (reordered > 0) logger.LogInformation("Reordered {Count} menu items", reordered);
} }
// Backfill label updates for menu items renamed via business changes. // Backfill label updates for menu items renamed via business changes.
@ -1546,11 +1564,13 @@ public static class DbInitializer
AppRoles.Director, AppRoles.AuthorizedSigner, AppRoles.Director, AppRoles.AuthorizedSigner,
}; };
// Menu keys cần grant: PurchaseEvaluations root + per-type group + 3 leaf // Menu keys cần grant: PurchaseEvaluations root + per-type group + 4 leaf
// ([Plan AA S24 t1] thêm WfView read-only matrix view).
var menuKeys = new List<string> { MenuKeys.PurchaseEvaluations }; var menuKeys = new List<string> { MenuKeys.PurchaseEvaluations };
foreach (var typeCode in MenuKeys.PurchaseEvaluationTypeCodes) foreach (var typeCode in MenuKeys.PurchaseEvaluationTypeCodes)
{ {
menuKeys.Add(MenuKeys.PurchaseEvaluationGroup(typeCode)); menuKeys.Add(MenuKeys.PurchaseEvaluationGroup(typeCode));
menuKeys.Add(MenuKeys.PurchaseEvaluationWorkflowView(typeCode));
menuKeys.Add(MenuKeys.PurchaseEvaluationList(typeCode)); menuKeys.Add(MenuKeys.PurchaseEvaluationList(typeCode));
menuKeys.Add(MenuKeys.PurchaseEvaluationCreate(typeCode)); menuKeys.Add(MenuKeys.PurchaseEvaluationCreate(typeCode));
menuKeys.Add(MenuKeys.PurchaseEvaluationPending(typeCode)); menuKeys.Add(MenuKeys.PurchaseEvaluationPending(typeCode));