[CLAUDE] Move nested-type menu → fe-user; Admin workflow config page
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m41s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m41s
User clarified: menu loại HĐ 3-level (Danh sách/Thao tác/Duyệt) thuộc
fe-user. Admin có page riêng để config quy trình per loại HĐ.
fe-admin Layout:
- filterForAdmin() drops Ct_* entries (hide nested type menu).
- Admin sidebar giờ về lại đơn giản: Dashboard / Master / Hợp đồng
(leaf) / Forms / Reports / System.
fe-user Layout:
- Dynamic menu tree từ /menus/me (thay fixed USER_MENU hardcoded).
- Recursive MenuNodeRenderer (top-level expanded, nested collapsed).
- resolvePath user-specific: Ct_*_List → /my-contracts?type=X,
Ct_*_Create → /contracts/new?type=X, Ct_*_Pending → /inbox?type=X.
- filterForUser drops admin-only entries (Master/System/Forms/Reports).
- Static USER_FIXED_TOP prepends "Hộp thư" leaf → /inbox.
- MyContractsPage + InboxPage đọc ?type=X param, filter client-side.
Workflow config (Admin side):
- Domain: WorkflowTypeAssignment entity (ContractType → PolicyName
override). Registry.ForContractWithOverrides() prefer DB override
else default.
- Infrastructure: EF config + migration AddWorkflowTypeAssignments,
unique index trên ContractType. ContractWorkflowService load
overrides dict mỗi transition. ContractFeatures load overrides khi
build WorkflowSummaryDto.
- Application: GetWorkflowAdminOverviewQuery returns 7 types × current
policy + available policies. SetWorkflowAssignmentCommand validate
policy name tồn tại; nếu = default thì delete override (no stale row).
- Api: GET /api/workflows + PUT /api/workflows/{contractType}
với policy "Workflows.Read" + "Workflows.Update".
- Menu: new key `Workflows` dưới System, label "Quy trình HĐ".
- FE /system/workflows: 7 card per type, dropdown Standard/SkipCcm +
'Đã override' badge khi khác default, phase sequence timeline,
explanation banner ở top. Iteration 2 note: admin-authored custom
policies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -1,16 +1,167 @@
|
||||
import { Link, NavLink, Outlet } from 'react-router-dom'
|
||||
import { Inbox, FileText, Plus } from 'lucide-react'
|
||||
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'
|
||||
|
||||
// Menu fixed cho fe-user (không show tree động vì user-flow đơn giản)
|
||||
const USER_MENU = [
|
||||
{ to: '/inbox', label: 'HĐ chờ xử lý', icon: Inbox },
|
||||
{ to: '/contracts/new', label: 'Tạo HĐ mới', icon: Plus },
|
||||
{ to: '/my-contracts', label: 'HĐ của tôi', icon: FileText },
|
||||
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,
|
||||
}
|
||||
|
||||
// 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: '/inbox', // user home = inbox
|
||||
Contracts: '/my-contracts',
|
||||
}
|
||||
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}`
|
||||
}
|
||||
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.
|
||||
const USER_HIDDEN_KEYS = new Set([
|
||||
'Master', 'Suppliers', 'Projects', 'Departments',
|
||||
'System', 'Users', 'Roles', 'Permissions',
|
||||
'Forms', 'Reports',
|
||||
])
|
||||
|
||||
function filterForUser(nodes: MenuNode[]): MenuNode[] {
|
||||
return nodes
|
||||
.filter(n => !USER_HIDDEN_KEYS.has(n.key))
|
||||
.map(n => ({ ...n, children: filterForUser(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 }) {
|
||||
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}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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, 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
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition',
|
||||
isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{node.label}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const { menu } = useAuth()
|
||||
const filteredMenu = filterForUser(menu)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white">
|
||||
@ -20,25 +171,11 @@ export function Layout() {
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">ERP</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
{USER_MENU.map(item => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition',
|
||||
isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
<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">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Inbox, AlertTriangle, Clock, FileText } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { DataTable, type Column } from '@/components/DataTable'
|
||||
@ -40,13 +40,17 @@ function StatCard({
|
||||
export function InboxPage() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const [searchParams] = useSearchParams()
|
||||
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['inbox'],
|
||||
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).data,
|
||||
})
|
||||
|
||||
const rows = list.data ?? []
|
||||
const allRows = list.data ?? []
|
||||
// Apply type filter from sidebar nested menu (?type=X)
|
||||
const rows = typeFilter == null ? allRows : allRows.filter(c => c.type === typeFilter)
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const now = Date.now()
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { FileText, Plus } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { DataTable, type Column } from '@/components/DataTable'
|
||||
@ -16,12 +17,20 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
export function MyContractsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['my-contracts'],
|
||||
queryKey: ['my-contracts', typeFilter],
|
||||
queryFn: async () => (await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
|
||||
})
|
||||
|
||||
// Filter client-side by URL type param (sidebar nested menu passes it)
|
||||
const rows = useMemo(() => {
|
||||
const items = list.data?.items ?? []
|
||||
return typeFilter == null ? items : items.filter(c => c.type === typeFilter)
|
||||
}, [list.data, typeFilter])
|
||||
|
||||
const columns: Column<ContractListItem>[] = [
|
||||
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
|
||||
{ key: 'tenHopDong', header: 'Tên', render: c => c.tenHopDong ?? '—' },
|
||||
@ -46,7 +55,7 @@ export function MyContractsPage() {
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={list.data?.items ?? []}
|
||||
rows={rows}
|
||||
getRowKey={c => c.id}
|
||||
isLoading={list.isLoading}
|
||||
empty={
|
||||
|
||||
Reference in New Issue
Block a user