diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index f44f924..cf337a9 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -13,6 +13,7 @@ import { PermissionsPage } from '@/pages/system/PermissionsPage' import { RolesPage } from '@/pages/system/RolesPage' import { WorkflowsPage } from '@/pages/system/WorkflowsPage' import { PeWorkflowsPage } from '@/pages/system/PeWorkflowsPage' +import { ApprovalWorkflowsV2Page } from '@/pages/system/ApprovalWorkflowsV2Page' import { FormsPage } from '@/pages/forms/FormsPage' import { ContractsListPage } from '@/pages/contracts/ContractsListPage' import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' @@ -51,6 +52,9 @@ function App() { } /> } /> } /> + {/* Quy trình duyệt MỚI (Mig 22 — UAT) */} + } /> + } /> } /> } /> } /> diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx index ac5e553..45ff110 100644 --- a/fe-admin/src/components/Layout.tsx +++ b/fe-admin/src/components/Layout.tsx @@ -91,6 +91,17 @@ function resolvePath(key: string): string | null { if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn') return `/system/pe-workflows/${code}` } + // Quy trình duyệt MỚI (Mig 22 — Session 17): root = group bowed, leaf = + // type-specific designer. Sau UAT thay thế PeWorkflows + Workflows cũ. + if (key === 'ApprovalWorkflowsV2') return '/system/approval-workflows-v2' + const awV2Match = key.match(/^AwV2_(.+)$/) + if (awV2Match) { + const code = awV2Match[1] + if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn' || code === 'Contract') { + return `/system/approval-workflows-v2/${code}` + } + } + return null } diff --git a/fe-admin/src/lib/menuKeys.ts b/fe-admin/src/lib/menuKeys.ts index cb6565c..84e32ed 100644 --- a/fe-admin/src/lib/menuKeys.ts +++ b/fe-admin/src/lib/menuKeys.ts @@ -14,6 +14,9 @@ export const MenuKeys = { Permissions: 'Permissions', PurchaseEvaluations: 'PurchaseEvaluations', PeWorkflows: 'PeWorkflows', + // Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08) + ApprovalWorkflowsV2: 'ApprovalWorkflowsV2', + AwV2_DuyetNcc: 'AwV2_DuyetNcc', } as const export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys] diff --git a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx new file mode 100644 index 0000000..9c77b2e --- /dev/null +++ b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx @@ -0,0 +1,638 @@ +// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08). +// Schema riêng UAT trước khi drop legacy. Cấu trúc: +// Quy trình (Mã + Tên + ApplicableType) +// Bước 1 — Phòng A +// Cấp 1 — NV X (1 user CỤ THỂ qua ApproverUserId) +// Cấp 2 — NV Y +// +// Khác Designer cũ (PE workflow): Levels match 1 NV chính xác (KHÔNG OR-of-many). +import { useMemo, useState, type FormEvent } from 'react' +import { useParams } from 'react-router-dom' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { GitBranch, Plus, Trash2, CheckCircle2, History, Workflow, ChevronUp, ChevronDown } from 'lucide-react' +import { toast } from 'sonner' +import { PageHeader } from '@/components/PageHeader' +import { Button } from '@/components/ui/Button' +import { Dialog } from '@/components/ui/Dialog' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/Label' +import { Select } from '@/components/ui/Select' +import { Textarea } from '@/components/ui/Textarea' +import { api } from '@/lib/api' +import { getErrorMessage } from '@/lib/apiError' +import type { Department, Paged } from '@/types/master' + +// ===== Types (mirror BE AwAdminOverviewDto) ===== + +type LevelDto = { + id: string + order: number + name: string | null + approverUserId: string + approverUserName: string | null + approverEmail: string | null +} +type StepDto = { + id: string + order: number + name: string // "Phòng A" — display + departmentId: string | null + departmentName: string | null + levels: LevelDto[] +} +type DefinitionDto = { + id: string + code: string + version: number + applicableType: number + applicableTypeLabel: string + name: string + description: string | null + isActive: boolean + activatedAt: string | null + createdAt: string + steps: StepDto[] +} +type TypeSummaryDto = { + applicableType: number + applicableTypeLabel: string + active: DefinitionDto | null + history: DefinitionDto[] +} + +type EditLevel = { name: string; approverUserId: string } +type EditStep = { name: string; departmentId: string | null; levels: EditLevel[] } + +// FE typeCode → BE int (giống MenuKeys ApplicableType) +const TYPE_CODE_TO_INT: Record = { + DuyetNcc: 1, + DuyetNccPhuongAn: 2, + Contract: 3, +} +const DEFAULT_CODE_BY_TYPE: Record = { + 1: 'QT-DN-V2-001', + 2: 'QT-DN-PA-V2-001', + 3: 'QT-HD-V2-001', +} + +function copyFromDefinition(d: DefinitionDto): EditStep[] { + return d.steps.map(s => ({ + name: s.name, + departmentId: s.departmentId, + levels: s.levels.map(l => ({ name: l.name ?? '', approverUserId: l.approverUserId })), + })) +} + +export function ApprovalWorkflowsV2Page() { + const qc = useQueryClient() + const { typeCode } = useParams<{ typeCode?: string }>() + const selectedTypeInt = typeCode ? TYPE_CODE_TO_INT[typeCode] : null + + const overview = useQuery({ + queryKey: ['approval-workflow-v2-overview', selectedTypeInt], + queryFn: async () => { + const params = selectedTypeInt ? { applicableType: selectedTypeInt } : {} + return (await api.get<{ types: TypeSummaryDto[] }>('/approval-workflows-v2', { params })).data + }, + }) + + const currentType = selectedTypeInt + ? overview.data?.types.find(t => t.applicableType === selectedTypeInt) + : null + + return ( +
+ + + {currentType + ? `Quy trình duyệt (Mới): ${currentType.applicableTypeLabel}` + : 'Quy trình duyệt (Mới)'} + + } + description={ + currentType + ? 'Mỗi Bước = 1 Phòng. Mỗi Cấp trong Bước = 1 nhân viên cụ thể duyệt. Tuần tự: Cấp 1 → Cấp 2 → ... → Bước kế.' + : 'Schema mới UAT — chọn loại quy trình từ menu bên trái.' + } + /> + + {overview.isLoading &&
Đang tải…
} + + {overview.data && !currentType && ( +
+ {overview.data.types.map(t => ( +
+
+

{t.applicableTypeLabel}

+ {t.active && ( + + {t.active.code} v{String(t.active.version).padStart(2, '0')} + + )} +
+
+ {t.active + ? `${t.active.steps.length} bước · ${t.active.steps.reduce((s, x) => s + x.levels.length, 0)} cấp · ${t.history.length} version` + : 'Chưa có quy trình'} +
+
+ ))} +
+ )} + + {currentType && ( + qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })} + /> + )} +
+ ) +} + +// ===== Per-type panel ===== + +function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) { + const [designerOpen, setDesignerOpen] = useState(false) + const [cloneFrom, setCloneFrom] = useState(null) + const qc = useQueryClient() + + const del = useMutation({ + mutationFn: async (id: string) => api.delete(`/approval-workflows-v2/${id}`), + onSuccess: () => { + toast.success('Đã xoá version') + qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] }) + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + return ( +
+ {type.active ? ( + { setCloneFrom(d); setDesignerOpen(true) }} + onDelete={() => { + if (confirm(`Xoá version đang áp dụng "${type.active!.code} v${type.active!.version}"?`)) { + del.mutate(type.active!.id) + } + }} + /> + ) : ( +
+ Chưa có quy trình cho loại này. Tạo version đầu tiên bên dưới. +
+ )} + +
+

Lịch sử versions

+ +
+ + {type.history.filter(d => !d.isActive).length === 0 && ( +
+ Chưa có version cũ. Khi tạo version mới, version hiện tại tự động archive. +
+ )} + +
+ {type.history.filter(d => !d.isActive).map(d => ( + { setCloneFrom(dd); setDesignerOpen(true) }} + onDelete={() => { + if (confirm(`Xoá version "${d.code} v${d.version}"?`)) del.mutate(d.id) + }} + /> + ))} +
+ + {designerOpen && ( + { setDesignerOpen(false); setCloneFrom(null) }} + onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }} + /> + )} +
+ ) +} + +// ===== Definition card (read-only) ===== + +function DefinitionCard({ + def, + isActive, + onClone, + onDelete, +}: { + def: DefinitionDto + isActive: boolean + onClone: (d: DefinitionDto) => void + onDelete: () => void +}) { + return ( +
+
+
+
+

{def.name}

+ + {def.code} v{String(def.version).padStart(2, '0')} + + {isActive ? ( + + + Đang áp dụng + + ) : ( + + + Archived + + )} +
+ {def.description &&

{def.description}

} + +
    + {def.steps.map(s => ( +
  1. +
    +
    + {s.order} +
    + Bước {s.order} — {s.name} + {s.departmentName && ( + + {s.departmentName} + + )} +
    +
      + {s.levels.length === 0 ? ( +
    • Chưa có cấp duyệt
    • + ) : ( + s.levels.map(l => ( +
    • + + C{l.order} + + {l.name || `Cấp ${l.order}`} + + + {l.approverUserName ?? l.approverUserId} + + {l.approverEmail && ( + ({l.approverEmail}) + )} +
    • + )) + )} +
    +
  2. + ))} +
+
+
+ + +
+
+
+ ) +} + +// ===== Designer dialog ===== + +function Designer({ + applicableType, + applicableTypeLabel, + cloneFrom, + onClose, + onSaved, +}: { + applicableType: number + applicableTypeLabel: string + cloneFrom: DefinitionDto | null + onClose: () => void + onSaved: () => void +}) { + const initialSteps: EditStep[] = useMemo( + () => + cloneFrom + ? copyFromDefinition(cloneFrom) + : [{ name: 'Phòng 1', departmentId: null, levels: [{ name: 'Cấp 1', approverUserId: '' }] }], + [cloneFrom], + ) + + const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001' + const [code, setCode] = useState(cloneFrom?.code ?? defaultCode) + const [name, setName] = useState(cloneFrom ? `${cloneFrom.name} (clone)` : `Quy trình ${applicableTypeLabel}`) + const [description, setDescription] = useState(cloneFrom?.description ?? '') + const [steps, setSteps] = useState(initialSteps) + + const usersList = useQuery({ + queryKey: ['users-for-approver-v2'], + queryFn: async () => + (await api.get<{ items: { id: string; fullName: string; email: string }[] }>('/users', { + params: { page: 1, pageSize: 200 }, + })).data.items, + }) + + const departmentsList = useQuery({ + queryKey: ['departments-list-v2'], + queryFn: async () => + (await api.get>('/departments', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const save = useMutation({ + mutationFn: async () => { + // Validate có user trong tất cả Cấp + for (const s of steps) { + if (s.levels.length === 0) throw new Error(`Bước "${s.name}" chưa có cấp duyệt nào.`) + for (const l of s.levels) { + if (!l.approverUserId) throw new Error(`Bước "${s.name}" có cấp chưa chọn nhân viên duyệt.`) + } + } + await api.post('/approval-workflows-v2', { + applicableType, + code, + name, + description: description || null, + steps: steps.map((s, i) => ({ + order: i + 1, + name: s.name, + departmentId: s.departmentId, + levels: s.levels.map((l, j) => ({ + order: j + 1, + name: l.name || null, + approverUserId: l.approverUserId, + })), + })), + }) + }, + onSuccess: () => { + toast.success('Đã lưu quy trình mới. Version cũ đã archive.') + onSaved() + }, + onError: err => toast.error(getErrorMessage(err)), + }) + + function submit(e: FormEvent) { + e.preventDefault() + if (steps.length === 0) { + toast.error('Phải có ít nhất 1 bước') + return + } + save.mutate() + } + + function moveStep(idx: number, dir: -1 | 1) { + const newIdx = idx + dir + if (newIdx < 0 || newIdx >= steps.length) return + const next = [...steps] + ;[next[idx], next[newIdx]] = [next[newIdx], next[idx]] + setSteps(next) + } + + function moveLevel(stepIdx: number, levelIdx: number, dir: -1 | 1) { + const step = steps[stepIdx] + const newIdx = levelIdx + dir + if (newIdx < 0 || newIdx >= step.levels.length) return + const next = [...step.levels] + ;[next[levelIdx], next[newIdx]] = [next[newIdx], next[levelIdx]] + setSteps(steps.map((x, i) => (i === stepIdx ? { ...x, levels: next } : x))) + } + + return ( + + + + + } + > +
+
+
+ + setCode(e.target.value)} required className="font-mono" /> +
Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.
+
+
+ + setName(e.target.value)} required /> +
+
+ +