Files
solution-erp/fe-user/src/components/Layout.tsx
pqhuy1987 6cbc6ad937
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s
[CLAUDE] App+Api+FE-User+FE-Admin+Tests: W2 KHKK — CRUD phieu nhap + can cu b.8-9 + FE 3 page x2 app + 12 fix reviewer
- BE mega-file ContractSigningPlanFeatures (Create 4-rao + auto-Lines snapshot per-winner + MaKeHoach @CREATE + UpdateDraft null-safe + Get/List/inbox-precompute-V2/deleted + Dossier + Attachments + Delete allow-list M6 + picker) + Controller 15 action policy per-action 15/15 (KeHoachKyKet.*)
- FE pages/khkk 3 page + types x2 app SHA-pair + 4-place (6 leaf Khkk_* het coming-soon; WfView: user=matrix?type=10 [whitelist +10] / admin=Designer deep-link); root KeHoachKyKet -> /khkk/list
- Reviewer adversarial: 12 finding (3 CRITICAL dut-2-bo FE-BE: route picker + peId body + dossier planId; 2 MAJOR security: detail+download 0 rao -> EnsureCanViewAsync mirror PE S89 nhap-rieng-tu; PUT-vs-POST de-ban-sao; peTenGoiThau; winnerSupplierNames; Content-Disposition RFC6266; LEFT-join Projects) -> 11 FIXED + 1 GIU (F-10 ke thua khuon PE, bit dong bo sau)
- tests: +8 (574 total 0 fail) — authz reflection dong + Create/Delete/guard/khong-dot-ma-khi-409 + fixture-co-interceptor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:19:41 +07:00

479 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
import { ChevronDown, Circle, type LucideIcon } from 'lucide-react'
import * as Icons from 'lucide-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
}
const TYPE_CODE_TO_INT: Record<string, number> = {
ThauPhu: 1,
GiaoKhoan: 2,
NhaCungCap: 3,
DichVu: 4,
MuaBan: 5,
NguyenTacNcc: 6,
NguyenTacDv: 7,
}
// Reverse lookup int → code (cho auto-expand từ URL ?type=)
const INT_TO_TYPE_CODE: Record<number, string> = Object.fromEntries(
Object.entries(TYPE_CODE_TO_INT).map(([k, v]) => [v, k]),
)
// Detect Ct_<Code> group key (no suffix, distinguish from leaf Ct_<Code>_List/Create/Pending)
const CT_GROUP_PATTERN = /^Ct_([^_]+)$/
function getCtGroupCode(key: string): string | null {
const m = key.match(CT_GROUP_PATTERN)
return m ? m[1] : null
}
// Pe_<Code> group key (Duyệt NCC / Duyệt NCC và Giải pháp). Riêng accordion
// để Pe_* mutex với nhau (không mix với Ct_*) — user có thể expand 1 Ct_ HĐ
// + 1 Pe_ phiếu cùng lúc vì 2 module khác nhau.
const PE_GROUP_PATTERN = /^Pe_([^_]+)$/
function getPeGroupCode(key: string): string | null {
const m = key.match(PE_GROUP_PATTERN)
return m ? m[1] : null
}
// PE type code → int (mirror BE PurchaseEvaluationType enum)
const PE_CODE_TO_INT: Record<string, number> = { DuyetNcc: 1, DuyetNccPhuongAn: 2 }
const INT_TO_PE_CODE: Record<number, string> = Object.fromEntries(
Object.entries(PE_CODE_TO_INT).map(([k, v]) => [v, k]),
)
// User-side menu key → route. Differs from admin: Danh sách points to
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = {
// [W2 KHKK S161] GĐ2 hết skeleton — 6 leaf re-point trang thật (gotcha #50:
// thiếu route = sidebar drop SILENT; filter đi qua query param, page map FILTER_TO_PHASE).
Khkk_List: '/khkk/list',
Khkk_Create: '/khkk/create',
Khkk_Pending: '/khkk/list?filter=ChoDuyet',
Khkk_Approved: '/khkk/list?filter=DaDuyet',
Hdc_ThauPhu: '/coming-soon?stage=4&t=01',
Hdc_GiaoKhoan: '/coming-soon?stage=4&t=02',
Hdc_NhaCungCap: '/coming-soon?stage=4&t=03',
Hdc_DichVu: '/coming-soon?stage=4&t=04',
Hdc_MuaBan: '/coming-soon?stage=4&t=05',
Hdc_NguyenTacNcc: '/coming-soon?stage=4&t=06',
Hdc_NguyenTacDv: '/coming-soon?stage=4&t=07',
Dashboard: '/dashboard',
Contracts: '/my-contracts',
PurchaseEvaluations: '/purchase-evaluations',
// [S159 2026-07-29] 2 root placeholder giai đoạn 2/4 (owner: menu hiển thị đúng 4 GĐ
// như tab strip). Module chưa build → trỏ về Tổng quan pipeline (khung "Sắp triển khai").
// LESSON gotcha #50: key DB mới PHẢI có route ở đây, không thì sidebar drop SILENT.
KeHoachKyKet: '/khkk/list', // [W2 S161] root GĐ2 hết placeholder — về danh sách phiếu
HopDongCung: '/dashboard',
// [W2 S161] 2 leaf còn lại — WfView đi alias matrix type=10 (App.tsx, không đẻ page mới)
Khkk_WfView: '/khkk/workflow-matrix?type=10',
Khkk_Deleted: '/khkk/list?view=deleted',
// [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ
// fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không
// MenuLeaf line 238 `if (!path) return null` → sidebar drop silent.
// Implementer Chunk B (06a441c) thêm Routes vào App.tsx + 4 page +
// menuKeys.ts nhưng quên mirror staticMap resolvePath → bug UAT bro.
Suppliers: '/master/suppliers',
Projects: '/master/projects',
Departments: '/master/departments',
CatalogUnits: '/master/catalogs/units',
CatalogMaterials: '/master/catalogs/materials',
CatalogServices: '/master/catalogs/services',
CatalogWorkItems: '/master/catalogs/work-items',
// [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 ~250 `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_Dashboard: '/office/dashboard',
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',
Hrm_Dashboard: '/hr/dashboard',
}
if (staticMap[key]) return staticMap[key]
// [S159-đợt5 owner "y chang Duyệt NCC"] +WfView/Approved/Deleted. WfView cả 7 loại
// đều ?type=3 (HĐ chung 1 bộ workflow V2) — thêm &ct=N để active-state riêng từng leaf.
const match = key.match(/^Ct_([^_]+)_(List|Create|Pending|WfView|Approved|Deleted)$/)
if (match) {
const [, code, action] = match
const typeInt = TYPE_CODE_TO_INT[code]
if (!typeInt) return null
if (action === 'List') return `/my-contracts?type=${typeInt}`
if (action === 'Create') return `/contracts/new?type=${typeInt}`
if (action === 'Pending') return `/inbox?type=${typeInt}`
if (action === 'WfView') return `/contracts/workflow-matrix?type=3&ct=${typeInt}`
if (action === 'Approved') return `/my-contracts?type=${typeInt}&phase=9`
if (action === 'Deleted') return `/my-contracts?type=${typeInt}&deleted=1`
}
// Pe_<Code>_<Action> cho module Duyệt NCC (user side)
// [S155 đợt 2 — owner restructure menu 2026-07-27] +`Approved` +`Deleted`. Đây là
// "chỗ thứ 4" của Pattern 16-bis (staticMap/resolvePath) — key seed ở BE mà quên nới
// regex thì `resolvePath` trả null ⇒ MenuLeaf DROP IM LẶNG khỏi sidebar (gotcha #50).
// 🔴 `WfView` GIỮ NGUYÊN chỉ ở fe-user (lead chốt đường (c)): fe-admin KHÔNG có
// `WorkflowMatrixViewPage` nên thêm `WfView` bên đó sẽ đẻ link chết.
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending|WfView|Approved|Deleted)$/)
if (peMatch) {
const [, code, action] = peMatch
const typeInt = PE_CODE_TO_INT[code]
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}`
// "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`
// [S155 đợt 2] 2 mục MỚI — CẢ HAI tái dùng `PurchaseEvaluationsListPage`, 0 route mới,
// 0 page mới. "Đã duyệt" chỉ là filter phase sẵn có (`DaDuyet = 7`, đã thông 3 tầng);
// "Đã xóa" là view riêng trong cùng page, gọi endpoint `/purchase-evaluations/deleted`.
// 🔴 `view=approved` là KHOÁ ĐỊNH DANH, KHÔNG phải bộ lọc — trang bỏ qua nó, chỉ
// `phase=7` mới thật sự lọc. Vì sao phải có: URL của mục "Đã duyệt" TRÙNG HỆT URL
// mà user tự lọc trạng thái "Đã duyệt" trên trang Danh sách ⇒ không phân biệt được
// bằng URL ⇒ menu sáng nhầm (bug UAT anh báo 2026-07-27). `phase` nằm trong
// TRANSIENT (là bộ lọc) nên KHÔNG dùng làm danh tính được; `view` thì không.
if (action === 'Approved') return `/purchase-evaluations?type=${typeInt}&phase=7&view=approved`
if (action === 'Deleted') return `/purchase-evaluations?type=${typeInt}&deleted=1`
}
return null
}
// Menu entries not applicable to user app — filtered out client-side so the
// sidebar only shows what matters to a contract drafter/approver.
// [Plan CA S29 2026-05-22] REMOVED `Master, Suppliers, Projects, Departments`
// khỏi hidden set — "Cấu hình danh mục dùng chung" giờ ở fe-user/eoffice cho
// role CatalogManager (admin tạo + gán qua Permission Matrix). Catalogs (root
// + 4 leaf) tree-inherit từ Master nên auto-visible. Forms/Reports/System/
// Users/Roles/Permissions vẫn ẩn (admin tools only).
const USER_HIDDEN_KEYS = new Set([
'System', 'Users', 'Roles', 'Permissions',
'Forms', 'Reports',
])
function filterForUser(nodes: MenuNode[]): MenuNode[] {
// Filter 2 tầng: hardcode USER_HIDDEN_KEYS (system-level, structural never-show)
// + dynamic isVisible (Mig 27 admin toggle qua MenuVisibilityPage). isVisible
// mặc định true, admin set false → ẩn khỏi sidebar eOffice.
return nodes
.filter(n => !USER_HIDDEN_KEYS.has(n.key) && n.isVisible !== false)
.map(n => ({ ...n, children: filterForUser(n.children) }))
}
// Mig 27: ưu tiên displayLabel admin custom, fallback label gốc.
function effectiveLabel(n: { label: string; displayLabel?: string | null }): string {
return (n.displayLabel && n.displayLabel.trim()) || n.label
}
// Accordion state cho groups Ct_<Code> (7 HĐ) + Pe_<Code> (2 phiếu) — mỗi
// family mutex độc lập. Auto-expand theo URL `?type=X` (Ct_ dùng route
// /contracts|/my-contracts|/inbox, Pe_ dùng /purchase-evaluations). Group
// không match prefix giữ behavior cũ (independent local useState).
type AccordionContextValue = {
expandedCtCode: string | null
setExpandedCtCode: (code: string | null) => void
expandedPeCode: string | null
setExpandedPeCode: (code: string | null) => void
}
const AccordionContext = createContext<AccordionContextValue>({
expandedCtCode: null,
setExpandedCtCode: () => {},
expandedPeCode: null,
setExpandedPeCode: () => {},
})
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 }) {
const ctCode = getCtGroupCode(node.key)
const peCode = getPeGroupCode(node.key)
const accordion = useContext(AccordionContext)
// Local state cho group thường (top-level "Hợp đồng" mặc định mở)
const [localOpen, setLocalOpen] = useState(depth === 0)
// Ct_<Code> / Pe_<Code> group: state controlled bởi accordion context,
// mỗi family (Ct / Pe) mutex độc lập.
const isCtAccordion = ctCode != null
const isPeAccordion = peCode != null
const isAccordion = isCtAccordion || isPeAccordion
const open = isCtAccordion
? accordion.expandedCtCode === ctCode
: isPeAccordion
? accordion.expandedPeCode === peCode
: localOpen
function toggle() {
if (isCtAccordion) {
accordion.setExpandedCtCode(open ? null : ctCode)
} else if (isPeAccordion) {
accordion.setExpandedPeCode(open ? null : peCode)
} else {
setLocalOpen(o => !o)
}
}
const Icon = getIcon(node.icon)
const isTopLevel = depth === 0
return (
<div>
<button
onClick={toggle}
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',
// Highlight Ct_ group đang active (accordion open) bằng tinted background
isAccordion && open && 'bg-slate-50 text-slate-900',
)}
>
{/* [Plan AA S24 t2 wrap fix] Bro UAT request: label dài 2 dòng phải về
đầu hàng (under icon, KHÔNG indent sau icon). Pattern: inline-block
icon + inline text → text wrap natural về left edge button.
ChevronDown absolute right để KHÔNG bị đẩy xuống khi text wrap.
Smaller text-[12px] + leading-snug compact 2-line height. */}
<Icon className="mr-1.5 -mt-0.5 inline-block h-4 w-4 align-middle" />
<span className="align-middle" title={effectiveLabel(node)}>{effectiveLabel(node)}</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). Dùng để distinguish /path?type=2 vs /path?type=2&pendingMe=1.
function queryMatches(current: string, target: string): boolean {
const a = new URLSearchParams(current)
const b = new URLSearchParams(target)
// 🔴 [S155 đợt 2] Transient chỉ được bỏ qua khi MENU ĐÍCH KHÔNG tự ghim key đó.
// Bug UAT 2026-07-27 (anh báo): click "Danh sách" mà menu sáng "Đã duyệt".
// Nguyên nhân: `phase` vừa là BỘ LỌC trạng thái trên trang danh sách (nên nằm
// trong TRANSIENT — fix 2026-05-08), vừa vừa-mới trở thành DANH TÍNH điều hướng
// của mục "Đã duyệt" (`?type=N&phase=7`, đợt 2). Strip cả 2 vế ⇒ "Danh sách"
// (`[type]`) và "Đã duyệt" (`[type]` sau khi mất `phase`) thành KHÔNG PHÂN BIỆT
// ĐƯỢC ⇒ URL `?type=1` khớp cả hai, mục render sau thắng.
// Cùng một tham số không thể vừa là thứ-bị-bỏ-qua vừa là thứ-định-danh; nên
// quyền quyết định thuộc về ĐÍCH: đích ghim `phase` ⇒ so khớp NGHIÊM; đích không
// ghim ⇒ giữ nguyên hành vi cũ (lọc/chọn dòng không làm mất highlight).
const pinnedByTarget = new Set(b.keys())
const ignorable = (k: string) => TRANSIENT_QUERY_KEYS.has(k) && !pinnedByTarget.has(k)
const aKeys = [...a.keys()].filter(k => !ignorable(k)).sort()
const bKeys = [...b.keys()].filter(k => !ignorable(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
// Custom active check: pathname match + query string match (set equality).
// Fix bug: /purchase-evaluations?type=2 và ?type=2&pendingMe=1 cùng highlight
// vì NavLink default chỉ check pathname.
const [targetPath, targetQuery = ''] = path.split('?')
const pathnameMatches = location.pathname === targetPath
const qMatches = queryMatches(location.search.replace(/^\?/, ''), targetQuery)
const isActive = pathnameMatches && qMatches
return (
<NavLink
to={path}
title={effectiveLabel(node)}
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',
// [S159-đợt5 owner] leaf cấp-0 render qua MenuLeaf ("Tổng quan quy trình") viết
// HOA đồng bộ group header cấp-0 (khuôn MenuGroup :260). 🔴 KHAI ĐÚNG PHẠM VI
// (reviewer FLAG-3): "Hộp thư" KHÔNG đi qua đây — nó là USER_FIXED_TOP render
// bằng StaticLeaf (lớp CSS riêng, vẫn chữ thường). Đồng bộ nốt = quyết owner.
depth === 0
? 'px-3 py-2 text-[11px] font-semibold uppercase tracking-wide'
: 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 → label dài
wrap về đầu hàng (under icon, KHÔNG indent sau icon). */}
<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">{effectiveLabel(node)}</span>
</NavLink>
)
}
// Static entries prepended to the dynamic menu tree — these are user-app
// specific (inbox + quick create) not backed by MenuItems DB rows.
const USER_FIXED_TOP: MenuNode[] = [
{ key: '__inbox', label: 'Hộp thư', parentKey: null, order: 0, icon: 'Inbox', canRead: true, canCreate: true, canUpdate: true, canDelete: true, isVisible: true, displayLabel: null, children: [] },
]
function staticResolvePath(key: string): string | null {
if (key === '__inbox') return '/inbox'
return null
}
function StaticLeaf({ node }: { node: MenuNode }) {
const Icon = getIcon(node.icon)
const path = staticResolvePath(node.key)
if (!path) return null
return (
<NavLink
to={path}
end
title={effectiveLabel(node)}
className={({ isActive }) =>
cn(
'relative block rounded-md px-3 py-1.5 text-[12px] font-medium leading-snug transition-colors',
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 — consistent
với MenuLeaf + MenuGroup. */}
<Icon className="mr-2 -mt-0.5 inline-block h-4 w-4 align-middle" />
<span className="align-middle">{effectiveLabel(node)}</span>
</NavLink>
)
}
export function Layout() {
const { menu } = useAuth()
const filteredMenu = filterForUser(menu)
const location = useLocation()
// Accordion state — 1 Ct_ HĐ + 1 Pe_ phiếu expand tối đa. Auto-sync theo
// URL: /contracts|/my-contracts|/inbox?type=N → Ct, /purchase-evaluations
// ?type=N → Pe. Khác pathname gốc → skip (không reset, giữ user choice).
const [expandedCtCode, setExpandedCtCode] = useState<string | null>(null)
const [expandedPeCode, setExpandedPeCode] = useState<string | null>(null)
useEffect(() => {
const params = new URLSearchParams(location.search)
const typeParam = params.get('type')
if (!typeParam) return
const n = Number(typeParam)
if (location.pathname.startsWith('/purchase-evaluations')) {
const code = INT_TO_PE_CODE[n]
if (code) setExpandedPeCode(code)
} else {
// /contracts, /my-contracts, /inbox, /contracts/new... — all use
// ContractType enum
const code = INT_TO_TYPE_CODE[n]
if (code) setExpandedCtCode(code)
}
}, [location.pathname, location.search])
const accordionValue = useMemo(
() => ({ expandedCtCode, setExpandedCtCode, expandedPeCode, setExpandedPeCode }),
[expandedCtCode, expandedPeCode],
)
return (
<AccordionContext.Provider value={accordionValue}>
{/* Brand stripe — dải nhận diện #1F7DC1 chạy suốt đỉnh app. 2026-06-16
anh "nâng màu": dày hơn (h-1.5) + dải gradient xanh đậm bắt mắt. */}
<div className="flex h-screen flex-col">
<div className="h-1.5 shrink-0 bg-gradient-to-r from-brand-700 via-brand-400 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">
{/* Logo cap — brand gradient wash đậm hơn (was brand-50/70). */}
<div className="flex h-16 items-center border-b border-brand-100 bg-gradient-to-r from-brand-50 via-brand-50/60 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="rounded-md bg-brand-600 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">ERP</span>
</Link>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{USER_FIXED_TOP.map(n => <StaticLeaf key={n.key} node={n} />)}
{filteredMenu.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>
</AccordionContext.Provider>
)
}