All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m56s
- 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>
479 lines
23 KiB
TypeScript
479 lines
23 KiB
TypeScript
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>
|
||
)
|
||
}
|