[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:
@ -9,6 +9,7 @@ import { SuppliersPage } from '@/pages/master/SuppliersPage'
|
||||
import { ProjectsPage } from '@/pages/master/ProjectsPage'
|
||||
import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
|
||||
import { PermissionsPage } from '@/pages/system/PermissionsPage'
|
||||
import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
|
||||
import { FormsPage } from '@/pages/forms/FormsPage'
|
||||
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
|
||||
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
||||
@ -35,6 +36,7 @@ function App() {
|
||||
<Route path="/master/departments" element={<DepartmentsPage />} />
|
||||
<Route path="/system/users" element={<UsersPage />} />
|
||||
<Route path="/system/permissions" element={<PermissionsPage />} />
|
||||
<Route path="/system/workflows" element={<WorkflowsPage />} />
|
||||
<Route path="/forms" element={<FormsPage />} />
|
||||
<Route path="/contracts" element={<ContractsListPage />} />
|
||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||
|
||||
@ -39,6 +39,7 @@ function resolvePath(key: string): string | null {
|
||||
Users: '/system/users',
|
||||
Roles: '/system/roles',
|
||||
Permissions: '/system/permissions',
|
||||
Workflows: '/system/workflows',
|
||||
}
|
||||
if (staticMap[key]) return staticMap[key]
|
||||
|
||||
@ -54,6 +55,18 @@ function resolvePath(key: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// Admin side: hide the per-ContractType submenu (Ct_*) — that's a user-app
|
||||
// concern. Admin manages workflow config via /system/workflows instead.
|
||||
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} />
|
||||
@ -138,7 +151,7 @@ export function Layout() {
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
|
||||
{menu.map(n => (
|
||||
{filterForAdmin(menu).map(n => (
|
||||
<MenuNodeRenderer key={n.key} node={n} depth={0} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
138
fe-admin/src/pages/system/WorkflowsPage.tsx
Normal file
138
fe-admin/src/pages/system/WorkflowsPage.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { GitBranch, Info } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { ContractPhaseLabel } from '@/types/contracts'
|
||||
|
||||
type WorkflowPolicyDto = {
|
||||
name: string
|
||||
description: string
|
||||
activePhases: number[]
|
||||
}
|
||||
|
||||
type WorkflowTypeAssignmentDto = {
|
||||
contractType: number
|
||||
contractTypeLabel: string
|
||||
currentPolicy: string
|
||||
defaultPolicy: string
|
||||
policy: WorkflowPolicyDto
|
||||
}
|
||||
|
||||
type WorkflowAdminOverviewDto = {
|
||||
availablePolicies: WorkflowPolicyDto[]
|
||||
assignments: WorkflowTypeAssignmentDto[]
|
||||
}
|
||||
|
||||
export function WorkflowsPage() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const overview = useQuery({
|
||||
queryKey: ['workflow-overview'],
|
||||
queryFn: async () => (await api.get<WorkflowAdminOverviewDto>('/workflows')).data,
|
||||
})
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async ({ contractType, policyName }: { contractType: number; policyName: string }) => {
|
||||
await api.put(`/workflows/${contractType}`, { policyName })
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workflow-overview'] })
|
||||
// Invalidate contract details too — FE gets fresh policy next time user opens
|
||||
qc.invalidateQueries({ queryKey: ['contract'] })
|
||||
toast.success('Đã cập nhật quy trình')
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
const data = overview.data
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
Quy trình duyệt hợp đồng
|
||||
</span>
|
||||
}
|
||||
description="Cấu hình quy trình duyệt cho từng loại HĐ. Mỗi loại có thể chọn 1 policy khác nhau."
|
||||
/>
|
||||
|
||||
<div className="mb-5 flex items-start gap-2 rounded-md border border-brand-100 bg-brand-50/50 px-4 py-3 text-xs text-slate-700">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-brand-600" />
|
||||
<div>
|
||||
<strong>Standard:</strong> quy trình đầy đủ 8 phase có CCM review — áp dụng cho HĐ Thầu phụ/Giao khoán/NCC.
|
||||
{' · '}
|
||||
<strong>SkipCcm:</strong> bỏ phase CCM, đi thẳng từ 'Đang in ký' → 'Đang trình ký' — áp dụng HĐ Dịch vụ/Mua bán/Nguyên tắc.
|
||||
{' · '}
|
||||
Đặt về policy mặc định = xóa override, registry dùng logic hardcoded.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overview.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||
|
||||
{data && (
|
||||
<div className="space-y-3">
|
||||
{data.assignments.map(a => {
|
||||
const isOverridden = a.currentPolicy !== a.defaultPolicy
|
||||
return (
|
||||
<div key={a.contractType} className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[15px] font-semibold text-slate-900">{a.contractTypeLabel}</h3>
|
||||
{isOverridden && (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700">
|
||||
Đã override
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-500">{a.policy.description}</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400">Các phase:</span>
|
||||
{a.policy.activePhases
|
||||
.filter(p => p !== 99) // hide TuChoi in timeline — it's a terminal error path
|
||||
.map((p, idx, arr) => (
|
||||
<span key={p} className="flex items-center">
|
||||
<span className="rounded bg-slate-100 px-2 py-0.5 text-[11px] text-slate-700">
|
||||
{ContractPhaseLabel[p] ?? p}
|
||||
</span>
|
||||
{idx < arr.length - 1 && <span className="mx-1 text-slate-300">→</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wider text-slate-400">Policy</label>
|
||||
<Select
|
||||
value={a.currentPolicy}
|
||||
onChange={e => update.mutate({ contractType: a.contractType, policyName: e.target.value })}
|
||||
disabled={update.isPending}
|
||||
className="w-40"
|
||||
>
|
||||
{data.availablePolicies.map(p => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
{p.name === a.defaultPolicy ? ' (mặc định)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 rounded-md border border-slate-200 bg-slate-50 p-4 text-xs text-slate-600">
|
||||
<strong>Iteration 2:</strong> cho phép tạo policy custom (phase sequence + SLA + role-per-phase) thay vì chọn
|
||||
từ 2 policy pre-built. Data model đã hỗ trợ — chỉ cần thêm UI builder.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user