Files
solution-erp/fe-admin/src/components/Layout.tsx
pqhuy1987 a8bbdaeeea
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s
[CLAUDE] FE: Văn phòng số foundation — shared PageHeader/KpiCard/WidgetCard + Dashboard landing (PURO · CSS Hồ sơ NS) + sync fe-admin index.css + menu Off_Dashboard
- 3 shared component PageHeader/KpiCard/WidgetCard ×2 app SHA256-identical; tái dùng token Hồ sơ NS (.app-gradient-brand/.card-accent/.icon-chip/.stat-value/.label-eyebrow + accent palette teal/violet/amberx/greenx); gotcha #66 text-white! trên gradient header.
- OfficeDashboardPage 2-cột widget kiểu PURO HomePage: Đề xuất/Đơn từ/Ticket/Phòng họp hôm nay + "Công việc của tôi" + Thao tác nhanh. Reuse query endpoint sẵn có (shared TanStack cache, KHÔNG BE/API mới), đếm client-side, loading/error/empty mỗi widget.
- Sync fe-admin/src/index.css ← fe-user (đóng drift S66-S68: heading 600→700, ink #0f172a→#0b1220, label-eyebrow slate→brand-600 + rule gotcha #66). Nay 2 app đồng bộ.
- Menu key Off_Dashboard (MenuKeys.cs const + All[] + DbInitializer seed Order 0 dưới Off) — no migration, idempotent. GIỮ ẨN non-Admin (RevokeTemporarilyHiddenModules StartsWith Off). Chưa golive.
- Wire 4-place ×2 app: App.tsx route /office/dashboard + menuKeys.ts + Layout staticMap.
- Fix KpiCard activeBorder -300 → -500 (accent palette chỉ 50/100/500/600/700 — chống "vỡ màu im lặng" Tailwind v4: border-teal-300 rơi default Tailwind, border-amberx-300 drop hẳn).
- Build PASS x2 (fe-user index-DrxDysO7 / fe-admin index-TbkadgKd) + dotnet slnx 0/0. reviewer PASS 0 blocker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:24:17 +07:00

285 lines
12 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 { 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_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',
// [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>
)
}