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 (
-