[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>
|
||||
)
|
||||
}
|
||||
@ -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={
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Contracts;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/workflows")]
|
||||
[Authorize(Policy = "Workflows.Read")]
|
||||
public class WorkflowsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<WorkflowAdminOverviewDto>> Overview(CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new GetWorkflowAdminOverviewQuery(), ct));
|
||||
|
||||
[HttpPut("{contractType:int}")]
|
||||
[Authorize(Policy = "Workflows.Update")]
|
||||
public async Task<IActionResult> SetAssignment(int contractType, [FromBody] SetWorkflowAssignmentBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new SetWorkflowAssignmentCommand((ContractType)contractType, body.PolicyName), ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record SetWorkflowAssignmentBody(string PolicyName);
|
||||
@ -22,6 +22,7 @@ public interface IApplicationDbContext
|
||||
DbSet<ContractAttachment> ContractAttachments { get; }
|
||||
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
|
||||
DbSet<Notification> Notifications { get; }
|
||||
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -337,6 +337,8 @@ public class GetContractQueryHandler(
|
||||
|
||||
var supplier = await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == c.SupplierId, ct);
|
||||
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
|
||||
var workflowOverrides = await db.WorkflowTypeAssignments.AsNoTracking()
|
||||
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
|
||||
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
|
||||
|
||||
// Resolve user names
|
||||
@ -376,14 +378,16 @@ public class GetContractQueryHandler(
|
||||
att.Id, att.FileName, att.StoragePath, att.FileSize,
|
||||
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
|
||||
.ToList(),
|
||||
BuildWorkflowSummary(c));
|
||||
BuildWorkflowSummary(c, workflowOverrides));
|
||||
}
|
||||
|
||||
// FE uses this to render next-phase buttons dynamically — no more hardcoded
|
||||
// NEXT_PHASES map that silently drifts from the BE policy.
|
||||
private static WorkflowSummaryDto BuildWorkflowSummary(Contract c)
|
||||
private static WorkflowSummaryDto BuildWorkflowSummary(
|
||||
Contract c,
|
||||
IReadOnlyDictionary<ContractType, string>? overrides)
|
||||
{
|
||||
var policy = WorkflowPolicyRegistry.ForContract(c);
|
||||
var policy = WorkflowPolicyRegistry.ForContractWithOverrides(c, overrides);
|
||||
return new WorkflowSummaryDto(
|
||||
PolicyName: policy.Name,
|
||||
PolicyDescription: policy.Description,
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Application.Contracts;
|
||||
|
||||
// Admin UI /system/workflows — list current policy assignment per ContractType
|
||||
// + change via dropdown. Iteration 2: let admin define custom policies; for
|
||||
// now they pick from WorkflowPolicyRegistry.AvailablePolicyNames.
|
||||
|
||||
public record WorkflowPhaseDto(int Phase, int? SlaDays, List<string> AllowedRolesAnyDir);
|
||||
|
||||
public record WorkflowPolicyDto(string Name, string Description, List<int> ActivePhases);
|
||||
|
||||
public record WorkflowTypeAssignmentDto(
|
||||
int ContractType,
|
||||
string ContractTypeLabel,
|
||||
string CurrentPolicy,
|
||||
string DefaultPolicy,
|
||||
WorkflowPolicyDto Policy);
|
||||
|
||||
public record WorkflowAdminOverviewDto(
|
||||
List<WorkflowPolicyDto> AvailablePolicies,
|
||||
List<WorkflowTypeAssignmentDto> Assignments);
|
||||
|
||||
public record GetWorkflowAdminOverviewQuery : IRequest<WorkflowAdminOverviewDto>;
|
||||
|
||||
public class GetWorkflowAdminOverviewQueryHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetWorkflowAdminOverviewQuery, WorkflowAdminOverviewDto>
|
||||
{
|
||||
private static readonly Dictionary<ContractType, string> TypeLabels = new()
|
||||
{
|
||||
[ContractType.HopDongThauPhu] = "HĐ Thầu phụ",
|
||||
[ContractType.HopDongGiaoKhoan] = "HĐ Giao khoán",
|
||||
[ContractType.HopDongNhaCungCap] = "HĐ Nhà cung cấp",
|
||||
[ContractType.HopDongDichVu] = "HĐ Dịch vụ",
|
||||
[ContractType.HopDongMuaBan] = "HĐ Mua bán",
|
||||
[ContractType.HopDongNguyenTacNCC] = "HĐ Nguyên tắc NCC",
|
||||
[ContractType.HopDongNguyenTacDichVu] = "HĐ Nguyên tắc Dịch vụ",
|
||||
};
|
||||
|
||||
public async Task<WorkflowAdminOverviewDto> Handle(GetWorkflowAdminOverviewQuery request, CancellationToken ct)
|
||||
{
|
||||
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
|
||||
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
|
||||
|
||||
var availablePolicies = WorkflowPolicyRegistry.AvailablePolicyNames
|
||||
.Select(WorkflowPolicyRegistry.ByName)
|
||||
.Select(p => new WorkflowPolicyDto(p.Name, p.Description, p.ActivePhases.Select(x => (int)x).ToList()))
|
||||
.ToList();
|
||||
|
||||
var assignments = Enum.GetValues<ContractType>()
|
||||
.Select(t =>
|
||||
{
|
||||
var defaultName = WorkflowPolicyRegistry.DefaultPolicyNameFor(t);
|
||||
var currentName = overrides.TryGetValue(t, out var n) ? n : defaultName;
|
||||
var policy = WorkflowPolicyRegistry.ByName(currentName);
|
||||
return new WorkflowTypeAssignmentDto(
|
||||
(int)t,
|
||||
TypeLabels.GetValueOrDefault(t, t.ToString()),
|
||||
currentName,
|
||||
defaultName,
|
||||
new WorkflowPolicyDto(policy.Name, policy.Description, policy.ActivePhases.Select(x => (int)x).ToList()));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new WorkflowAdminOverviewDto(availablePolicies, assignments);
|
||||
}
|
||||
}
|
||||
|
||||
public record SetWorkflowAssignmentCommand(ContractType ContractType, string PolicyName) : IRequest;
|
||||
|
||||
public class SetWorkflowAssignmentCommandValidator : AbstractValidator<SetWorkflowAssignmentCommand>
|
||||
{
|
||||
public SetWorkflowAssignmentCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ContractType).IsInEnum();
|
||||
RuleFor(x => x.PolicyName).NotEmpty()
|
||||
.Must(name => WorkflowPolicyRegistry.AvailablePolicyNames.Contains(name))
|
||||
.WithMessage(x => $"Policy '{x.PolicyName}' không tồn tại. Cho phép: {string.Join(",", WorkflowPolicyRegistry.AvailablePolicyNames)}.");
|
||||
}
|
||||
}
|
||||
|
||||
public class SetWorkflowAssignmentCommandHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<SetWorkflowAssignmentCommand>
|
||||
{
|
||||
public async Task Handle(SetWorkflowAssignmentCommand request, CancellationToken ct)
|
||||
{
|
||||
var existing = await db.WorkflowTypeAssignments
|
||||
.FirstOrDefaultAsync(a => a.ContractType == request.ContractType, ct);
|
||||
|
||||
// If user sets policy back to the hardcoded default, delete the override
|
||||
// row so the registry uses the code-level default (no stale DB noise).
|
||||
var isDefault = request.PolicyName == WorkflowPolicyRegistry.DefaultPolicyNameFor(request.ContractType);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
if (isDefault) return; // nothing to persist
|
||||
db.WorkflowTypeAssignments.Add(new WorkflowTypeAssignment
|
||||
{
|
||||
ContractType = request.ContractType,
|
||||
PolicyName = request.PolicyName,
|
||||
});
|
||||
}
|
||||
else if (isDefault)
|
||||
{
|
||||
db.WorkflowTypeAssignments.Remove(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.PolicyName = request.PolicyName;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@ -117,23 +117,40 @@ public static class WorkflowPolicies
|
||||
|
||||
public static class WorkflowPolicyRegistry
|
||||
{
|
||||
// Mapping contract type → policy. Tuned to the real business from
|
||||
// QT-TP-NCC.docx: formal NTP/NCC/Giao khoán need full CCM review; service /
|
||||
// purchase / framework contracts skip CCM.
|
||||
public static WorkflowPolicy For(ContractType type) => type switch
|
||||
// Available policy names — extend when admin-authored custom policies
|
||||
// are added in iteration 2.
|
||||
public static readonly string[] AvailablePolicyNames = ["Standard", "SkipCcm"];
|
||||
|
||||
public static WorkflowPolicy ByName(string name) => name switch
|
||||
{
|
||||
ContractType.HopDongThauPhu => WorkflowPolicies.Standard,
|
||||
ContractType.HopDongGiaoKhoan => WorkflowPolicies.Standard,
|
||||
ContractType.HopDongNhaCungCap => WorkflowPolicies.Standard,
|
||||
ContractType.HopDongDichVu => WorkflowPolicies.SkipCcm,
|
||||
ContractType.HopDongMuaBan => WorkflowPolicies.SkipCcm,
|
||||
ContractType.HopDongNguyenTacNCC => WorkflowPolicies.SkipCcm,
|
||||
ContractType.HopDongNguyenTacDichVu => WorkflowPolicies.SkipCcm,
|
||||
"SkipCcm" => WorkflowPolicies.SkipCcm,
|
||||
_ => WorkflowPolicies.Standard,
|
||||
};
|
||||
|
||||
// Instance-level bypass flag overrides the default: if a contract has
|
||||
// Default mapping per contract type — used when DB override missing.
|
||||
// Matches business from QT-TP-NCC.docx.
|
||||
public static string DefaultPolicyNameFor(ContractType type) => type switch
|
||||
{
|
||||
ContractType.HopDongThauPhu or ContractType.HopDongGiaoKhoan or ContractType.HopDongNhaCungCap => "Standard",
|
||||
_ => "SkipCcm",
|
||||
};
|
||||
|
||||
public static WorkflowPolicy For(ContractType type) => ByName(DefaultPolicyNameFor(type));
|
||||
|
||||
// Instance-level bypass flag overrides the policy — if a contract has
|
||||
// BypassProcurementAndCCM=true, always use SkipCcm regardless of type.
|
||||
public static WorkflowPolicy ForContract(Contract contract) =>
|
||||
contract.BypassProcurementAndCCM ? WorkflowPolicies.SkipCcm : For(contract.Type);
|
||||
|
||||
// DB-aware resolver: prefer assignment, else default. Pass in the
|
||||
// assignments dict once per request (cached from DB).
|
||||
public static WorkflowPolicy ForContractWithOverrides(
|
||||
Contract contract,
|
||||
IReadOnlyDictionary<ContractType, string>? assignments)
|
||||
{
|
||||
if (contract.BypassProcurementAndCCM) return WorkflowPolicies.SkipCcm;
|
||||
if (assignments is not null && assignments.TryGetValue(contract.Type, out var policyName))
|
||||
return ByName(policyName);
|
||||
return For(contract.Type);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Contracts;
|
||||
|
||||
// Admin-configurable: which WorkflowPolicy applies to each ContractType.
|
||||
// Seed with hardcoded defaults (see DbInitializer); admin can switch via
|
||||
// /system/workflows UI without code changes. Later iteration will let admin
|
||||
// define custom policies with bespoke phase/role/SLA.
|
||||
public class WorkflowTypeAssignment : BaseEntity
|
||||
{
|
||||
public ContractType ContractType { get; set; }
|
||||
public string PolicyName { get; set; } = string.Empty; // "Standard" | "SkipCcm" (extensible)
|
||||
}
|
||||
@ -16,6 +16,7 @@ public static class MenuKeys
|
||||
public const string Users = "Users";
|
||||
public const string Roles = "Roles";
|
||||
public const string Permissions = "Permissions";
|
||||
public const string Workflows = "Workflows";
|
||||
|
||||
// Per-contract-type menu groups + 3 action leaves each.
|
||||
// Key format: Ct_<TypeCode>[_<Action>] — prefix `Ct_` distinguishes from
|
||||
@ -35,7 +36,7 @@ public static class MenuKeys
|
||||
Dashboard,
|
||||
Master, Suppliers, Projects, Departments,
|
||||
Contracts, Forms, Reports,
|
||||
System, Users, Roles, Permissions,
|
||||
System, Users, Roles, Permissions, Workflows,
|
||||
];
|
||||
|
||||
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];
|
||||
|
||||
@ -27,6 +27,7 @@ public class ApplicationDbContext
|
||||
public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>();
|
||||
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class WorkflowTypeAssignmentConfiguration : IEntityTypeConfiguration<WorkflowTypeAssignment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WorkflowTypeAssignment> e)
|
||||
{
|
||||
e.ToTable("WorkflowTypeAssignments");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.ContractType).HasConversion<int>();
|
||||
e.Property(x => x.PolicyName).HasMaxLength(50).IsRequired();
|
||||
e.HasIndex(x => x.ContractType).IsUnique();
|
||||
}
|
||||
}
|
||||
@ -113,6 +113,7 @@ public static class DbInitializer
|
||||
(MenuKeys.Users, "Người dùng", MenuKeys.System, 91, "User"),
|
||||
(MenuKeys.Roles, "Vai trò", MenuKeys.System, 92, "Shield"),
|
||||
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"),
|
||||
(MenuKeys.Workflows, "Quy trình HĐ", MenuKeys.System, 94, "GitBranch"),
|
||||
};
|
||||
|
||||
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWorkflowTypeAssignments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkflowTypeAssignments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ContractType = table.Column<int>(type: "int", nullable: false),
|
||||
PolicyName = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WorkflowTypeAssignments", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkflowTypeAssignments_ContractType",
|
||||
table: "WorkflowTypeAssignments",
|
||||
column: "ContractType",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkflowTypeAssignments");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -374,6 +374,40 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("ContractComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Contracts.WorkflowTypeAssignment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("ContractType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("PolicyName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ContractType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("WorkflowTypeAssignments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@ -36,7 +36,11 @@ public class ContractWorkflowService(
|
||||
if (contract.Phase == targetPhase)
|
||||
throw new ConflictException("HĐ đã ở phase đích.");
|
||||
|
||||
var policy = WorkflowPolicyRegistry.ForContract(contract);
|
||||
// Admin may override the default policy per ContractType via the
|
||||
// /system/workflows page. Load all overrides once (7 rows max).
|
||||
var overrides = await db.WorkflowTypeAssignments.AsNoTracking()
|
||||
.ToDictionaryAsync(a => a.ContractType, a => a.PolicyName, ct);
|
||||
var policy = WorkflowPolicyRegistry.ForContractWithOverrides(contract, overrides);
|
||||
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user