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>
243 lines
9.3 KiB
TypeScript
243 lines
9.3 KiB
TypeScript
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
|
|
import { ChevronDown, Circle, type LucideIcon } from 'lucide-react'
|
|
import * as Icons from 'lucide-react'
|
|
import { useState } from 'react'
|
|
import { useAuth } from '@/contexts/AuthContext'
|
|
import { TopBar } from '@/components/TopBar'
|
|
import type { MenuNode } from '@/types/menu'
|
|
import { cn } from '@/lib/cn'
|
|
|
|
function getIcon(name: string | null): LucideIcon {
|
|
if (!name) return Circle
|
|
const candidate = (Icons as unknown as Record<string, LucideIcon>)[name]
|
|
return candidate ?? Circle
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// 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',
|
|
MenuVisibility: '/system/menu-visibility',
|
|
Workflows: '/system/workflows',
|
|
CatalogUnits: '/master/catalogs/units',
|
|
CatalogMaterials: '/master/catalogs/materials',
|
|
CatalogServices: '/master/catalogs/services',
|
|
CatalogWorkItems: '/master/catalogs/work-items',
|
|
PurchaseEvaluations: '/purchase-evaluations',
|
|
PeWorkflows: '/system/pe-workflows',
|
|
Budgets: '/budgets',
|
|
Bg_List: '/budgets',
|
|
Bg_Create: '/budgets/new',
|
|
Bg_Pending: '/budgets?phase=Pending',
|
|
}
|
|
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`
|
|
}
|
|
|
|
// Workflow admin per ContractType: Wf_<Code> → /system/workflows/<code>
|
|
const wfMatch = key.match(/^Wf_(.+)$/)
|
|
if (wfMatch) {
|
|
const code = wfMatch[1]
|
|
if (TYPE_CODE_TO_INT[code]) return `/system/workflows/${code}`
|
|
}
|
|
|
|
// Pe_<Code>_<Action> cho module Duyệt NCC
|
|
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending)$/)
|
|
if (peMatch) {
|
|
const [, code, action] = peMatch
|
|
const PE_CODE_TO_INT: Record<string, number> = { DuyetNcc: 1, DuyetNccPhuongAn: 2 }
|
|
const typeInt = PE_CODE_TO_INT[code]
|
|
if (!typeInt) return null
|
|
if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
|
|
// "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ũ
|
|
// (PeDetailTabs "Sửa header" button vẫn navigate sang đó).
|
|
if (action === 'Create') return `/purchase-evaluations/workspace?type=${typeInt}`
|
|
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
|
|
}
|
|
// PE workflow admin leaf: PeWf_<Code> → /system/pe-workflows/<code>
|
|
const peWfMatch = key.match(/^PeWf_(.+)$/)
|
|
if (peWfMatch) {
|
|
const code = peWfMatch[1]
|
|
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn') return `/system/pe-workflows/${code}`
|
|
}
|
|
|
|
// Quy trình duyệt MỚI (Mig 22 — Session 17): root = group bowed, leaf =
|
|
// type-specific designer. Sau UAT thay thế PeWorkflows + Workflows cũ.
|
|
if (key === 'ApprovalWorkflowsV2') return '/system/approval-workflows-v2'
|
|
const awV2Match = key.match(/^AwV2_(.+)$/)
|
|
if (awV2Match) {
|
|
const code = awV2Match[1]
|
|
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn' || code === 'Contract') {
|
|
return `/system/approval-workflows-v2/${code}`
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// Admin side: hide the per-ContractType contract submenu (Ct_*) — that's a
|
|
// user-app concern. Keep Wf_* workflow-admin leaves.
|
|
function isAdminHidden(key: string): boolean {
|
|
return key.startsWith('Ct_')
|
|
}
|
|
|
|
function filterForAdmin(nodes: MenuNode[]): MenuNode[] {
|
|
return nodes
|
|
.filter(n => !isAdminHidden(n.key))
|
|
.map(n => ({ ...n, children: filterForAdmin(n.children) }))
|
|
}
|
|
|
|
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={cn(
|
|
'flex w-full items-center justify-between rounded-md transition',
|
|
isTopLevel
|
|
? 'px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:bg-slate-100 whitespace-nowrap'
|
|
: 'px-3 py-1.5 text-[13px] font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
|
)}
|
|
>
|
|
{/* [Plan AA S24 t1] Revert truncate Plan U S23 t11 — mirror fe-user
|
|
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">
|
|
<Icon className="h-4 w-4 shrink-0" />
|
|
<span title={node.label}>{node.label}</span>
|
|
</span>
|
|
<ChevronDown className={cn('h-3.5 w-3.5 shrink-0 text-slate-400 transition', !open && '-rotate-90')} />
|
|
</button>
|
|
{open && (
|
|
<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 => (
|
|
<MenuNodeRenderer key={c.key} node={c} depth={depth + 1} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Transient query keys — không phải "navigation identity", strip trước khi
|
|
// compare để menu giữ highlight khi user select row / search / filter.
|
|
// Ví dụ leaf "Danh sách" `?type=1` vẫn highlight khi user click phiếu →
|
|
// `?type=1&id=abc`. Trước đó exact-set match → mất highlight (bug UAT 2026-05-08).
|
|
const TRANSIENT_QUERY_KEYS = new Set(['id', 'q', 'editHeader', 'page', 'phase', 'awId'])
|
|
|
|
// So sánh 2 query string dạng key-value set (thứ tự param không quan trọng,
|
|
// transient keys ignored). Fix bug: /contracts?type=1 và ?type=1&pendingMe=1
|
|
// cùng highlight vì NavLink built-in `end` prop chỉ match pathname.
|
|
function queryMatches(current: string, target: string): boolean {
|
|
const a = new URLSearchParams(current)
|
|
const b = new URLSearchParams(target)
|
|
const aKeys = [...a.keys()].filter(k => !TRANSIENT_QUERY_KEYS.has(k)).sort()
|
|
const bKeys = [...b.keys()].filter(k => !TRANSIENT_QUERY_KEYS.has(k)).sort()
|
|
if (aKeys.length !== bKeys.length) return false
|
|
return aKeys.every((k, i) => bKeys[i] === k && a.get(k) === b.get(k))
|
|
}
|
|
|
|
function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) {
|
|
const Icon = getIcon(node.icon)
|
|
const path = resolvePath(node.key)
|
|
const location = useLocation()
|
|
if (!path) return null
|
|
const isDeep = depth >= 2
|
|
|
|
const [targetPath, targetQuery = ''] = path.split('?')
|
|
const isActive = location.pathname === targetPath
|
|
&& queryMatches(location.search.replace(/^\?/, ''), targetQuery)
|
|
|
|
return (
|
|
<NavLink
|
|
to={path}
|
|
title={node.label}
|
|
className={cn(
|
|
'flex min-w-0 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={cn('shrink-0', isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
|
{/* [Plan AA S24 t1] Revert truncate Plan U S23 t11 — mirror fe-user. */}
|
|
<span>{node.label}</span>
|
|
</NavLink>
|
|
)
|
|
}
|
|
|
|
export function Layout() {
|
|
const { menu } = useAuth()
|
|
|
|
return (
|
|
<div className="flex h-screen">
|
|
<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">
|
|
<Link to="/dashboard" className="flex items-center gap-2.5">
|
|
<img src="/logo.png" alt="Solutions" className="h-8 w-auto" />
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Admin</span>
|
|
</Link>
|
|
</div>
|
|
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
|
|
{filterForAdmin(menu).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>
|
|
)
|
|
}
|