Files
solution-erp/fe-admin/src/components/Layout.tsx
pqhuy1987 a737196b21 [CLAUDE] FE-Admin+FE-User: PurchaseEvaluation pages (3-panel list + tabs detail)
Types + pages + components cho module Duyệt NCC ở cả 2 FE (copy-share).

Pages:
 - PurchaseEvaluationsListPage: 3-panel lg:grid-cols-[340px_1fr_360px]
   * Panel 1: list filter theo type/phase/search + pendingMe inbox mode
   * Panel 2: PeDetailTabs (Thông tin/NCC/Hạng mục/Duyệt/Lịch sử)
   * Panel 3: PeWorkflowPanel với timeline + nextPhase buttons
   * Mobile fallback fullpage /purchase-evaluations/:id
 - PurchaseEvaluationCreatePage: form create/edit header (Type / Tên gói thầu
   / Dự án / Địa điểm / Mô tả / PaymentTerms JSON). Suppliers+Details+Quotes
   thêm sau khi save ở Detail tabs.

Components:
 - PeDetailTabs: 5 tab + dialogs (AddSupplier/EditSupplier/DetailDialog/
   QuoteDialog) + matrix N NCC × M hạng mục clickable cells + select winner
 - PeWorkflowPanel: policy timeline từ BE workflow.activePhases + transition
   confirmation dialog với comment

Routes (cả 2 app):
 - /purchase-evaluations (+ ?type=1|2&pendingMe=1&id=...)
 - /purchase-evaluations/new (+ ?type / ?id để edit)
 - /purchase-evaluations/:id (mobile fullpage)

Menu resolver:
 - Pe_<Code>_List → /purchase-evaluations?type=N
 - Pe_<Code>_Create → /purchase-evaluations/new?type=N
 - Pe_<Code>_Pending → /purchase-evaluations?type=N&pendingMe=1
 - PeWf_<Code> (fe-admin only) → /system/pe-workflows/<code>

Skip MVP: PE Workflow admin designer UI, PE Attachments. TS build pass
cả 2 app.
2026-04-23 16:56:26 +07:00

200 lines
6.8 KiB
TypeScript

import { Link, NavLink, Outlet } 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',
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',
}
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}`
if (action === 'Create') return `/purchase-evaluations/new?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}`
}
return null
}
// Admin side: hide the per-ContractType contract submenu (Ct_*) — that's a
// user-app concern. Keep Wf_* workflow-admin leaves.
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(
'flex w-full items-center justify-between rounded-md transition',
isTopLevel
? 'px-3 py-2 text-xs font-semibold uppercase tracking-wider text-slate-500 hover:bg-slate-100'
: 'px-3 py-1.5 text-[13px] font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)}
>
<span className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{node.label}
</span>
<ChevronDown className={cn('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>
)
}
function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) {
const Icon = getIcon(node.icon)
const path = resolvePath(node.key)
if (!path) return null
const isDeep = depth >= 2
return (
<NavLink
to={path}
// NavLink's default "startsWith" match causes /contracts?type=1 and
// /contracts to both highlight. Use `end` for query-param variants.
end={path.includes('?')}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-md transition',
isDeep ? 'px-3 py-1 text-[12px]' : 'px-3 py-2 text-sm font-medium',
isActive
? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)
}
>
<Icon className={cn(isDeep ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
{node.label}
</NavLink>
)
}
export function Layout() {
const { menu } = useAuth()
return (
<div className="flex h-screen">
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
<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">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>
)
}