[CLAUDE] Move nested-type menu → fe-user; Admin workflow config page
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:
pqhuy1987
2026-04-21 22:41:05 +07:00
parent 48e91fe7ca
commit 5e0f3801a1
20 changed files with 1737 additions and 48 deletions

View 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 CCM review áp dụng cho 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 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 chọn
từ 2 policy pre-built. Data model đã hỗ trợ chỉ cần thêm UI builder.
</div>
</div>
)
}