[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

@ -9,6 +9,7 @@ import { SuppliersPage } from '@/pages/master/SuppliersPage'
import { ProjectsPage } from '@/pages/master/ProjectsPage' import { ProjectsPage } from '@/pages/master/ProjectsPage'
import { DepartmentsPage } from '@/pages/master/DepartmentsPage' import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
import { PermissionsPage } from '@/pages/system/PermissionsPage' import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { WorkflowsPage } from '@/pages/system/WorkflowsPage'
import { FormsPage } from '@/pages/forms/FormsPage' import { FormsPage } from '@/pages/forms/FormsPage'
import { ContractsListPage } from '@/pages/contracts/ContractsListPage' import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
@ -35,6 +36,7 @@ function App() {
<Route path="/master/departments" element={<DepartmentsPage />} /> <Route path="/master/departments" element={<DepartmentsPage />} />
<Route path="/system/users" element={<UsersPage />} /> <Route path="/system/users" element={<UsersPage />} />
<Route path="/system/permissions" element={<PermissionsPage />} /> <Route path="/system/permissions" element={<PermissionsPage />} />
<Route path="/system/workflows" element={<WorkflowsPage />} />
<Route path="/forms" element={<FormsPage />} /> <Route path="/forms" element={<FormsPage />} />
<Route path="/contracts" element={<ContractsListPage />} /> <Route path="/contracts" element={<ContractsListPage />} />
<Route path="/contracts/new" element={<ContractCreatePage />} /> <Route path="/contracts/new" element={<ContractCreatePage />} />

View File

@ -39,6 +39,7 @@ function resolvePath(key: string): string | null {
Users: '/system/users', Users: '/system/users',
Roles: '/system/roles', Roles: '/system/roles',
Permissions: '/system/permissions', Permissions: '/system/permissions',
Workflows: '/system/workflows',
} }
if (staticMap[key]) return staticMap[key] if (staticMap[key]) return staticMap[key]
@ -54,6 +55,18 @@ function resolvePath(key: string): string | null {
return 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 }) { function MenuNodeRenderer({ node, depth = 0 }: { node: MenuNode; depth?: number }) {
const hasChildren = node.children.length > 0 const hasChildren = node.children.length > 0
if (hasChildren) return <MenuGroup node={node} depth={depth} /> if (hasChildren) return <MenuGroup node={node} depth={depth} />
@ -138,7 +151,7 @@ export function Layout() {
</Link> </Link>
</div> </div>
<nav className="flex-1 space-y-1 overflow-y-auto p-3"> <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} /> <MenuNodeRenderer key={n.key} node={n} depth={0} />
))} ))}
</nav> </nav>

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>
)
}

View File

@ -1,16 +1,167 @@
import { Link, NavLink, Outlet } from 'react-router-dom' 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 { TopBar } from '@/components/TopBar'
import type { MenuNode } from '@/types/menu'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
// Menu fixed cho fe-user (không show tree động vì user-flow đơn giản) function getIcon(name: string | null): LucideIcon {
const USER_MENU = [ if (!name) return Circle
{ to: '/inbox', label: 'HĐ chờ xử lý', icon: Inbox }, const candidate = (Icons as unknown as Record<string, LucideIcon>)[name]
{ to: '/contracts/new', label: 'Tạo HĐ mới', icon: Plus }, return candidate ?? Circle
{ to: '/my-contracts', label: 'HĐ của tôi', icon: FileText }, }
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() { export function Layout() {
const { menu } = useAuth()
const filteredMenu = filterForUser(menu)
return ( return (
<div className="flex h-screen"> <div className="flex h-screen">
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white"> <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> <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">ERP</span>
</Link> </Link>
</div> </div>
<nav className="flex-1 space-y-1 p-3"> <nav className="flex-1 space-y-1 overflow-y-auto p-3">
{USER_MENU.map(item => { {USER_FIXED_TOP.map(n => <StaticLeaf key={n.key} node={n} />)}
const Icon = item.icon {filteredMenu.map(n => (
return ( <MenuNodeRenderer key={n.key} node={n} depth={0} />
<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> </nav>
</aside> </aside>
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">

View File

@ -1,6 +1,6 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' 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 { Inbox, AlertTriangle, Clock, FileText } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable' import { DataTable, type Column } from '@/components/DataTable'
@ -40,13 +40,17 @@ function StatCard({
export function InboxPage() { export function InboxPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { user } = useAuth() const { user } = useAuth()
const [searchParams] = useSearchParams()
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
const list = useQuery({ const list = useQuery({
queryKey: ['inbox'], queryKey: ['inbox'],
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).data, 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 stats = useMemo(() => {
const now = Date.now() const now = Date.now()

View File

@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' 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 { FileText, Plus } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable' import { DataTable, type Column } from '@/components/DataTable'
@ -16,12 +17,20 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function MyContractsPage() { export function MyContractsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
const list = useQuery({ const list = useQuery({
queryKey: ['my-contracts'], queryKey: ['my-contracts', typeFilter],
queryFn: async () => (await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data, 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>[] = [ const columns: Column<ContractListItem>[] = [
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> }, { 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 ?? '—' }, { key: 'tenHopDong', header: 'Tên', render: c => c.tenHopDong ?? '—' },
@ -46,7 +55,7 @@ export function MyContractsPage() {
<DataTable <DataTable
columns={columns} columns={columns}
rows={list.data?.items ?? []} rows={rows}
getRowKey={c => c.id} getRowKey={c => c.id}
isLoading={list.isLoading} isLoading={list.isLoading}
empty={ empty={

View File

@ -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);

View File

@ -22,6 +22,7 @@ public interface IApplicationDbContext
DbSet<ContractAttachment> ContractAttachments { get; } DbSet<ContractAttachment> ContractAttachments { get; }
DbSet<ContractCodeSequence> ContractCodeSequences { get; } DbSet<ContractCodeSequence> ContractCodeSequences { get; }
DbSet<Notification> Notifications { get; } DbSet<Notification> Notifications { get; }
DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
} }

View File

@ -337,6 +337,8 @@ public class GetContractQueryHandler(
var supplier = await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == c.SupplierId, ct); 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 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); var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
// Resolve user names // Resolve user names
@ -376,14 +378,16 @@ public class GetContractQueryHandler(
att.Id, att.FileName, att.StoragePath, att.FileSize, att.Id, att.FileName, att.StoragePath, att.FileSize,
att.ContentType, att.Purpose, att.Note, att.CreatedAt)) att.ContentType, att.Purpose, att.Note, att.CreatedAt))
.ToList(), .ToList(),
BuildWorkflowSummary(c)); BuildWorkflowSummary(c, workflowOverrides));
} }
// FE uses this to render next-phase buttons dynamically — no more hardcoded // FE uses this to render next-phase buttons dynamically — no more hardcoded
// NEXT_PHASES map that silently drifts from the BE policy. // 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( return new WorkflowSummaryDto(
PolicyName: policy.Name, PolicyName: policy.Name,
PolicyDescription: policy.Description, PolicyDescription: policy.Description,

View File

@ -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);
}
}

View File

@ -117,23 +117,40 @@ public static class WorkflowPolicies
public static class WorkflowPolicyRegistry public static class WorkflowPolicyRegistry
{ {
// Mapping contract type → policy. Tuned to the real business from // Available policy names — extend when admin-authored custom policies
// QT-TP-NCC.docx: formal NTP/NCC/Giao khoán need full CCM review; service / // are added in iteration 2.
// purchase / framework contracts skip CCM. public static readonly string[] AvailablePolicyNames = ["Standard", "SkipCcm"];
public static WorkflowPolicy For(ContractType type) => type switch
public static WorkflowPolicy ByName(string name) => name switch
{ {
ContractType.HopDongThauPhu => WorkflowPolicies.Standard, "SkipCcm" => WorkflowPolicies.SkipCcm,
ContractType.HopDongGiaoKhoan => WorkflowPolicies.Standard,
ContractType.HopDongNhaCungCap => WorkflowPolicies.Standard,
ContractType.HopDongDichVu => WorkflowPolicies.SkipCcm,
ContractType.HopDongMuaBan => WorkflowPolicies.SkipCcm,
ContractType.HopDongNguyenTacNCC => WorkflowPolicies.SkipCcm,
ContractType.HopDongNguyenTacDichVu => WorkflowPolicies.SkipCcm,
_ => WorkflowPolicies.Standard, _ => 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. // BypassProcurementAndCCM=true, always use SkipCcm regardless of type.
public static WorkflowPolicy ForContract(Contract contract) => public static WorkflowPolicy ForContract(Contract contract) =>
contract.BypassProcurementAndCCM ? WorkflowPolicies.SkipCcm : For(contract.Type); 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);
}
} }

View File

@ -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)
}

View File

@ -16,6 +16,7 @@ public static class MenuKeys
public const string Users = "Users"; public const string Users = "Users";
public const string Roles = "Roles"; public const string Roles = "Roles";
public const string Permissions = "Permissions"; public const string Permissions = "Permissions";
public const string Workflows = "Workflows";
// Per-contract-type menu groups + 3 action leaves each. // Per-contract-type menu groups + 3 action leaves each.
// Key format: Ct_<TypeCode>[_<Action>] — prefix `Ct_` distinguishes from // Key format: Ct_<TypeCode>[_<Action>] — prefix `Ct_` distinguishes from
@ -35,7 +36,7 @@ public static class MenuKeys
Dashboard, Dashboard,
Master, Suppliers, Projects, Departments, Master, Suppliers, Projects, Departments,
Contracts, Forms, Reports, Contracts, Forms, Reports,
System, Users, Roles, Permissions, System, Users, Roles, Permissions, Workflows,
]; ];
public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"]; public static readonly string[] Actions = ["Read", "Create", "Update", "Delete"];

View File

@ -27,6 +27,7 @@ public class ApplicationDbContext
public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>(); public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>();
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>(); public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
public DbSet<Notification> Notifications => Set<Notification>(); public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<WorkflowTypeAssignment> WorkflowTypeAssignments => Set<WorkflowTypeAssignment>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {

View File

@ -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();
}
}

View File

@ -113,6 +113,7 @@ public static class DbInitializer
(MenuKeys.Users, "Người dùng", MenuKeys.System, 91, "User"), (MenuKeys.Users, "Người dùng", MenuKeys.System, 91, "User"),
(MenuKeys.Roles, "Vai trò", MenuKeys.System, 92, "Shield"), (MenuKeys.Roles, "Vai trò", MenuKeys.System, 92, "Shield"),
(MenuKeys.Permissions, "Phân quyền", MenuKeys.System, 93, "KeyRound"), (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 // Per-type sub-menu under Contracts: 1 group + 3 leaves each

View File

@ -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");
}
}
}

View File

@ -374,6 +374,40 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("ContractComments", (string)null); 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 => modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")

View File

@ -36,7 +36,11 @@ public class ContractWorkflowService(
if (contract.Phase == targetPhase) if (contract.Phase == targetPhase)
throw new ConflictException("HĐ đã ở phase đích."); 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 isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove; var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;