All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s
- Mig 50 ReplaceBudgetModuleWithPeWorkItemBudgets: bang moi PeWorkItemBudgets (1 record/cap Du an x Hang muc, UNIQUE filtered [IsDeleted]=0) + drop 5 bang Budget cu + PE/Contracts drop BudgetId + backfill BudgetManualAmount->BudgetPeriodAmount TRUOC DropColumn (phieu UAT giu so) + DELETE menu/permission Bg_* IN-list children-first
- BE: PUT {id}/budget/pro (role Procurement) + {id}/budget/ccm (role CostControl, Adjustment cho phep AM) fail-closed Forbidden-truoc-side-effect + EnsureTrackedAsync race-safe (catch unique -> re-fetch winner, loi khac rethrow) + auto-create record khi tao phieu + budgetSummary DTO (luy ke trinh-truoc/chon-thau-truoc/de-xuat-ky-nay + full fallback du-tru-PRO + canEdit flags) + submit-guard (3) doi predicate BudgetPeriodAmount -> "chua nhap Ngan sach ky nay" + PATCH budget-adjust absolute-set 2 field moi + Contract GIU BudgetManual* (HD nhap tay khong doi) + ke thua HD map BudgetPeriodAmount
- FE x2 app SHA256 identical: bang "TONG HOP NGAN SACH TRINH KY" block A (full dam + ban hanh + V0 hieu chinh + du tru PRO + ghi chu, editable theo canEditPro/canEditCcm) + block B 9 dong cong thuc Excel (5=1+3, 6=2+4, 7=full-5, 8 tu nhap default 7, 9=4+8) + to mau vuot ngan sach #C00000 / am do / red-soft row8>row7 + "Chua chon" khi count=0 + banner phieu chua gan Hang muc + o "Ngan sach ky nay" o create/header + XOA pages/components/types budgets + routes + menuKeys + Layout staticMap 4-place
- Tests: +22 PeWorkItemBudgetTests (auto-create x3, ensure/race x2, authz matrix PRO x5 + CCM x3, budgetSummary aggregates x5, adjust x4) - 14 BudgetPolicyTests xoa theo module - 1 test via-BudgetId -> 263 PASS (45 Domain + 218 Infra, 0 fail)
- database-agent advise adopted: khong FK vat ly PE/Contracts->Budgets (DropColumn khong can DropForeignKey) + DropIndex truoc DropColumn (SQL 5074) + IN-list thay LIKE Bg_% (underscore wildcard + miss root) + khong Serializable wrap (nested-tx conflict codegen)
- Reviewer PASS-with-minor 0 blocker (verdict-first survived); 2 minor da sua truoc commit (comment adjustMut absolute-set + dead key budgetId); note: F4 approver-edit-budget UI entry tam drafter-only, BE van cho approver scope - cho UAT anh Kiet
- Scaffold-bug caught: EF tu sinh RenameColumn BudgetManualAmount->ExpectedRemainingAmount (SAI semantics) -> thay bang Add+UPDATE+Drop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
284 lines
12 KiB
TypeScript
284 lines
12 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',
|
||
// [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON
|
||
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
|
||
// — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent.
|
||
Hrm_HoSo: '/employees',
|
||
// [Phase 10.2 G-H2 S35 2026-05-28] Cấu hình HRM 4 catalog leaf (Mig 35).
|
||
// Pattern 16-bis 4-place mirror (staticMap = 4th place, dễ miss nhất).
|
||
Hrm_Config_LeaveTypes: '/hrm/configs/leave-types',
|
||
Hrm_Config_Holidays: '/hrm/configs/holidays',
|
||
Hrm_Config_Shifts: '/hrm/configs/shifts',
|
||
Hrm_Config_OtPolicies: '/hrm/configs/ot-policies',
|
||
// [P11-C S51] danh mục xe công + tài xế — cùng page :kind-driven.
|
||
Hrm_Config_Vehicles: '/hrm/configs/vehicles',
|
||
Hrm_Config_Drivers: '/hrm/configs/drivers',
|
||
// [Phase 10.2 G-O1 S34 2026-05-27] Module Văn phòng số — Danh bạ nội bộ.
|
||
// 4-place mirror Pattern 16-bis: types/ + pages/ + App.tsx + menuKeys + staticMap.
|
||
Off_DanhBa: '/directory',
|
||
// [Phase 10.2 G-O2 S36 2026-05-28] Phòng họp Booking + Catalog (Mig 36).
|
||
// Pattern 16-bis 4-place mirror 7× cumulative — staticMap = 4th place dễ miss.
|
||
// View leaf + Book leaf đều trỏ calendar (user perspective); Manage trỏ catalog.
|
||
Off_PhongHop_View: '/meeting-calendar',
|
||
Off_PhongHop_Book: '/meeting-calendar',
|
||
Off_PhongHop_Manage: '/meeting-rooms',
|
||
Off_DeXuat_List: '/proposals',
|
||
Off_DeXuat_Create: '/proposals/new',
|
||
Off_DeXuat_Inbox: '/proposals?status=2&inboxOnly=true',
|
||
// Phase 10.3-10.4 S38 — Workflow Apps skeleton (8 entries)
|
||
Off_DonTu_Leave: '/workflow-apps/leave',
|
||
Off_DonTu_Ot: '/workflow-apps/ot',
|
||
Off_DonTu_Travel: '/workflow-apps/travel',
|
||
Off_DatXe: '/workflow-apps/vehicle',
|
||
Off_ItTicket: '/it-tickets',
|
||
Off_ChamCong: '/attendance',
|
||
// [P11-E S52] Báo cáo chấm công — admin-only leaf (route từ App.tsx S52).
|
||
Off_AttendanceReport: '/attendance/report',
|
||
Hrm_Dashboard: '/hr/dashboard',
|
||
}
|
||
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.
|
||
// [S57] BỎ ẩn "Danh mục" (Master/Catalogs + Cấu hình HRM re-parent vào đây) — gom
|
||
// master data về 1 chỗ cho CẢ admin lẫn nhân viên (đảo S29 hide). Admin nay quản
|
||
// master trực tiếp trên fe-admin; write vẫn khóa Admin/CatalogManager ở BE controller.
|
||
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(
|
||
'relative block w-full rounded-md text-left transition',
|
||
isTopLevel
|
||
? 'px-3 py-2 pr-7 text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:bg-slate-100 whitespace-nowrap'
|
||
: 'px-3 py-1.5 pr-7 text-[12px] font-medium leading-snug text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||
)}
|
||
>
|
||
{/* [Plan AA S24 t2 wrap fix] inline-block icon + inline text — label
|
||
dài wrap về đầu hàng (under icon, KHÔNG indent sau icon). Mirror
|
||
fe-user rule §3.9. ChevronDown absolute right. */}
|
||
<Icon className="mr-1.5 -mt-0.5 inline-block h-4 w-4 align-middle" />
|
||
<span className="align-middle" title={node.label}>{node.label}</span>
|
||
<ChevronDown className={cn(
|
||
'absolute right-2 top-2 h-3.5 w-3.5 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(
|
||
// Density-first: active leaf gets a brand left-rail + tint (crisp
|
||
// selected affordance, NAMGROUP). Inactive stays quiet slate.
|
||
'relative block rounded-md leading-snug transition-colors',
|
||
isDeep ? 'px-3 py-1 text-[11px]' : 'px-3 py-1.5 text-[12px] font-medium',
|
||
isActive
|
||
? 'bg-brand-50 font-semibold text-brand-700 before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full before:bg-brand-600'
|
||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||
)}
|
||
>
|
||
{/* [Plan AA S24 t2 wrap fix] inline-block icon + inline text → mirror fe-user. */}
|
||
<Icon className={cn('mr-2 -mt-0.5 inline-block align-middle', isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
||
<span className="align-middle">{node.label}</span>
|
||
</NavLink>
|
||
)
|
||
}
|
||
|
||
export function Layout() {
|
||
const { menu } = useAuth()
|
||
|
||
return (
|
||
// Brand stripe — dải nhận diện #1F7DC1 đỉnh app (mirror fe-user S58,
|
||
// anh yêu cầu "trang trí lên 1 tý"; guide accent sparing).
|
||
<div className="flex h-screen flex-col">
|
||
<div className="h-1 shrink-0 bg-gradient-to-r from-brand-700 via-brand-500 to-brand-600" />
|
||
<div className="flex min-h-0 flex-1">
|
||
<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-brand-100 bg-gradient-to-b from-brand-50/70 to-transparent 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-brand-600/80">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>
|
||
</div>
|
||
)
|
||
}
|