Files
solution-erp/fe-user/src/components/Layout.tsx
pqhuy1987 e54a22de0c
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s
[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S38 G-O4+G-O5+G-O6+G-P1+G-H3 SKELETON full-stack
Phase 10.3-10.4 SKELETON 5 plan combo finish — Mig 39+40 + BE skeleton 7 module +
FE 2 app SHA256 IDENTICAL + 11 menu key. UAT visible end-to-end.

⚠️ SKELETON Phase 1 trade-off rõ:
  - Status flat 5-state WorkflowAppStatus enum share Leave/OT/Travel/Vehicle
  - ApproveV2 workflow advance DEFER Phase 11 (Drafter Create OK, Approve flow chưa wire)
  - LevelOpinions per-module DEFER Phase 11
  - LeaveBalance calc + Auto-assign + SLA timer DEFER Phase 11
  - CodeGen atomic + MaDonTu/MaTicket gen DEFER Phase 11
  - Vehicle catalog + Driver catalog DEFER Phase 11 (free text VehicleLicense)
  - ItTicketComments thread DEFER Phase 11 (free text Resolution field)

Mig 39 (em main solo): 5 entity Workflow Apps schema
  - LeaveRequest (G-O4, FK LeaveType Hrm Mig 35, ApplicableType=5)
  - OtRequest (G-O4, FK OtPolicy optional, ApplicableType=6)
  - TravelRequest (G-O4, reuse ApplicableType=4 Proposal)
  - VehicleBooking (G-O5, free text vehicle, ApplicableType=7)
  - ItTicket (G-O6, NO workflow V2 — kanban status flow)

Mig 40 (em main solo): Attendance entity (G-P1)
  - GPS lat/long check-in/out + Source enum Web/Mobile/Device
  - UNIQUE composite (UserId, AttendanceDate)
  - WorkHours computed simple diff (NO OtPolicy multiplier yet)

BE CQRS (em main solo, single mega ~1100 LOC):
  - WorkflowAppsFeatures.cs 7 region (5 module Create+List + Attendance CheckIn/Out/GetMonth + HrDashboard)
  - 7 Controller: /api/leave-requests + /ot-requests + /travel-requests + /vehicle-bookings + /it-tickets + /attendances + /hr/dashboard
  - Class-level [Authorize] any authenticated
  - 13 endpoint total

FE 2 app (em main solo fallback gotcha #53 risk):
  - types/workflowApps.ts × 2 SHA256 IDENTICAL 77470e182a15de88 (all DTOs + Status badge)
  - WorkflowAppsListPage.tsx × 2 IDENTICAL 58139d0301a60ddf — generic declarative KIND_CONFIG handles 4 module (Leave/OT/Travel/Vehicle)
  - ItTicketsPage.tsx × 2 IDENTICAL d3062de2f54c794c — kanban 5 status column
  - MyAttendancePage.tsx × 2 IDENTICAL 86da48ae147db012 — GPS check-in/out + tháng calendar
  - HrmDashboardPage.tsx × 2 IDENTICAL d9c6c12a5a8694f8 — 4 KPI card + gender ratio + status breakdown
  - Pattern 16-bis 9× cumulative (App.tsx +4 routes + menuKeys +8 const + Layout staticMap +7 entry)
  - 7 amber banner "Skeleton Phase 1 — full feature Phase 11" rõ ràng UAT

Menu seed: +11 const + SeedMenuTreeAsync 8 row (Off_DonTu sub-group + 3 leaf + Off_DatXe + Off_ItTicket + Off_ChamCong + Hrm_Dashboard).
DbInitializer Sample workflow seed DEFER (workflows V2 already seeded từ S29+S37 reuse — admin clone tạo riêng per ApplicableType=5/6/7).

Verify:
- dotnet build PASS 0 error 2 pre-existing warning
- dotnet test 130/130 PASS baseline preserve
- npm build × 2 PASS clean
- SHA256 verify 5 file × 2 app all IDENTICAL

Plan G-* progress 11/11  (100% COMPLETE):
   G-H1 (S33) + G-O1 (S34) + G-H2 (S35) + G-O2 (S36) + G-O3 (S37) +
   G-O4 + G-O5 + G-O6 + G-P1 + G-H3 (S38 skeleton)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:19:42 +07:00

406 lines
17 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> = {
Dashboard: '/dashboard',
Contracts: '/my-contracts',
PurchaseEvaluations: '/purchase-evaluations',
Budgets: '/budgets',
Bg_List: '/budgets',
Bg_Create: '/budgets/new',
Bg_Pending: '/budgets?phase=Pending',
// [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',
// [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',
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 `/my-contracts?type=${typeInt}`
if (action === 'Create') return `/contracts/new?type=${typeInt}`
if (action === 'Pending') return `/inbox?type=${typeInt}`
}
// Pe_<Code>_<Action> cho module Duyệt NCC (user side)
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending|WfView)$/)
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`
}
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)
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
// 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(
'block rounded-md leading-snug transition',
isDeep ? 'px-3 py-1 text-[11px]' : 'px-3 py-2 text-[12px] font-medium',
isActive
? 'bg-brand-50 text-brand-700'
: '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(
'block rounded-md px-3 py-2 text-[12px] font-medium leading-snug transition',
isActive ? 'bg-brand-50 text-brand-700' : '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}>
<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">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>
</AccordionContext.Provider>
)
}