From 5e0f3801a13573be67a4a99028ad77f09bb551dc Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 22:41:05 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Move=20nested-type=20menu=20?= =?UTF-8?q?=E2=86=92=20fe-user;=20Admin=20workflow=20config=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fe-admin/src/App.tsx | 2 + fe-admin/src/components/Layout.tsx | 15 +- fe-admin/src/pages/system/WorkflowsPage.tsx | 138 +++ fe-user/src/components/Layout.tsx | 187 ++- fe-user/src/pages/InboxPage.tsx | 8 +- .../src/pages/contracts/MyContractsPage.tsx | 15 +- .../Controllers/WorkflowsController.cs | 27 + .../Interfaces/IApplicationDbContext.cs | 1 + .../Contracts/ContractFeatures.cs | 10 +- .../Contracts/WorkflowAdminFeatures.cs | 119 ++ .../Contracts/WorkflowPolicy.cs | 41 +- .../Contracts/WorkflowTypeAssignment.cs | 13 + .../SolutionErp.Domain/Identity/MenuKeys.cs | 3 +- .../Persistence/ApplicationDbContext.cs | 1 + .../WorkflowTypeAssignmentConfiguration.cs | 17 + .../Persistence/DbInitializer.cs | 1 + ...913_AddWorkflowTypeAssignments.Designer.cs | 1102 +++++++++++++++++ ...260421153913_AddWorkflowTypeAssignments.cs | 45 + .../ApplicationDbContextModelSnapshot.cs | 34 + .../Services/ContractWorkflowService.cs | 6 +- 20 files changed, 1737 insertions(+), 48 deletions(-) create mode 100644 fe-admin/src/pages/system/WorkflowsPage.tsx create mode 100644 src/Backend/SolutionErp.Api/Controllers/WorkflowsController.cs create mode 100644 src/Backend/SolutionErp.Application/Contracts/WorkflowAdminFeatures.cs create mode 100644 src/Backend/SolutionErp.Domain/Contracts/WorkflowTypeAssignment.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/WorkflowTypeAssignmentConfiguration.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421153913_AddWorkflowTypeAssignments.Designer.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260421153913_AddWorkflowTypeAssignments.cs diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index 946c10d..bb532a1 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx index 13a220e..7c65f9c 100644 --- a/fe-admin/src/components/Layout.tsx +++ b/fe-admin/src/components/Layout.tsx @@ -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 @@ -138,7 +151,7 @@ export function Layout() { diff --git a/fe-admin/src/pages/system/WorkflowsPage.tsx b/fe-admin/src/pages/system/WorkflowsPage.tsx new file mode 100644 index 0000000..21cd41f --- /dev/null +++ b/fe-admin/src/pages/system/WorkflowsPage.tsx @@ -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('/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 ( +
+ + + Quy trình duyệt hợp đồng + + } + 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." + /> + +
+ +
+ Standard: quy trình đầy đủ 8 phase có CCM review — áp dụng cho HĐ Thầu phụ/Giao khoán/NCC. + {' · '} + SkipCcm: 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. +
+
+ + {overview.isLoading &&
Đang tải…
} + + {data && ( +
+ {data.assignments.map(a => { + const isOverridden = a.currentPolicy !== a.defaultPolicy + return ( +
+
+
+
+

{a.contractTypeLabel}

+ {isOverridden && ( + + Đã override + + )} +
+

{a.policy.description}

+ +
+ Các phase: + {a.policy.activePhases + .filter(p => p !== 99) // hide TuChoi in timeline — it's a terminal error path + .map((p, idx, arr) => ( + + + {ContractPhaseLabel[p] ?? p} + + {idx < arr.length - 1 && } + + ))} +
+
+
+ + +
+
+
+ ) + })} +
+ )} + +
+ Iteration 2: 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. +
+
+ ) +} diff --git a/fe-user/src/components/Layout.tsx b/fe-user/src/components/Layout.tsx index 0833c33..0a51bcd 100644 --- a/fe-user/src/components/Layout.tsx +++ b/fe-user/src/components/Layout.tsx @@ -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)[name] + return candidate ?? Circle +} + +const TYPE_CODE_TO_INT: Record = { + 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 = { + 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 + return +} + +function MenuGroup({ node, depth }: { node: MenuNode; depth: number }) { + const [open, setOpen] = useState(depth === 0) + const Icon = getIcon(node.icon) + const isTopLevel = depth === 0 + + return ( +
+ + {open && ( +
+ {node.children.map(c => ( + + ))} +
+ )} +
+ ) +} + +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 ( + + 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', + ) + } + > + + {node.label} + + ) +} + +// 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 ( + + 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', + ) + } + > + + {node.label} + + ) +} + export function Layout() { + const { menu } = useAuth() + const filteredMenu = filterForUser(menu) + return (
-