// 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 (N NV duyệt) // Cấp 2 (N NV duyệt) // Cấp 3 (N NV duyệt) // // Iteration 3 (UAT feedback 2026-05-08): // - TỐI ĐA 3 cấp/bước (không có cấp 4). 1/2/3 cấp đều OK — quy trình // chạy theo số cấp thật sự có. // - MỖI CẤP CÓ N NV: convention multiple Level rows cùng Order = same Cấp. // BE iterate group by Order; trong cùng cấp = OR-of-N approvers. // - 3 section cố định C1/C2/C3 trong UI. Mỗi section có nút "+ Thêm NV" // + Trash xóa từng NV. // - Sequential gating: C2 disabled khi C1 chưa có NV. C3 disabled khi C2 // chưa có NV. Chặn xóa NV cuối C1 khi C2/C3 còn entries. // - Select NV CHỈ filter theo Phòng đã chọn (đổi Phòng → clear approvers). 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, Pin, PinOff } 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 // Mig 29 (S21 t5) — 5 Allow* options per slot Approver // Mig 30 (S22+5) — +AllowApproverEditBudget cho Section ngân sách // Mig 31 (S23 t1) — +AllowApproverSkipToFinal F2 storage swap Users→Level (per-Approver-slot) allowReturnOneLevel: boolean allowReturnOneStep: boolean allowReturnToAssignee: boolean allowReturnToDrafter: boolean allowApproverEditDetails: boolean allowApproverEditBudget: boolean allowApproverSkipToFinal: boolean } 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 isUserSelectable: boolean // Mig 25 — admin toggle cho user pick // Mig 29 (S21 t5) — 6 Allow* options MOVED: // - 5 flag F1+F3 xuống per slot Level (xem LevelDto) // - 1 flag F2 AllowDrafterSkipToFinal xuống per User (User Management) activatedAt: string | null createdAt: string steps: StepDto[] } type TypeSummaryDto = { applicableType: number applicableTypeLabel: string active: DefinitionDto | null history: DefinitionDto[] } type LevelOrder = 1 | 2 | 3 type EditLevelEntry = { order: LevelOrder approverUserId: string // Mig 29 (S21 t5) — 5 Allow* per slot (default backward compat S17: chỉ // AllowReturnToDrafter=true, 4 còn lại false). // Mig 30 (S22+5) — +AllowApproverEditBudget cho Section ngân sách (default false). // Mig 31 (S23 t1) — +AllowApproverSkipToFinal F2 admin opt-in per-Approver-slot (default false). allowReturnOneLevel: boolean allowReturnOneStep: boolean allowReturnToAssignee: boolean allowReturnToDrafter: boolean allowApproverEditDetails: boolean allowApproverEditBudget: boolean allowApproverSkipToFinal: boolean } type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] } type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null } // Tối đa 3 cấp/bước per UAT iter 3 (2026-05-08). Quy trình chạy theo số cấp // thật sự có (1 / 2 / 3). Mỗi cấp có N NV (multiple Level rows cùng Order). const MAX_LEVELS_PER_STEP = 3 const LEVEL_ORDERS: LevelOrder[] = [1, 2, 3] // 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 makeEmptyStep(stepNo: number, deptId: string | null = null): EditStep { return { name: `Phòng ${stepNo}`, departmentId: deptId, levelEntries: [], } } // Clone existing definition: filter Order ∈ {1,2,3}, drop entries vượt giới hạn. // Mig 29 (S21 t5) — clone 5 Allow* per slot từ existing Level. function copyFromDefinition(d: DefinitionDto): EditStep[] { return d.steps.map(s => ({ name: s.name, departmentId: s.departmentId, levelEntries: s.levels .filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP) .map(l => ({ order: l.order as LevelOrder, approverUserId: l.approverUserId, allowReturnOneLevel: l.allowReturnOneLevel ?? false, allowReturnOneStep: l.allowReturnOneStep ?? false, allowReturnToAssignee: l.allowReturnToAssignee ?? false, allowReturnToDrafter: l.allowReturnToDrafter ?? true, allowApproverEditDetails: l.allowApproverEditDetails ?? false, allowApproverEditBudget: l.allowApproverEditBudget ?? false, allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false, })), })) } // Mig 29 — Factory default cho entry mới (admin click "+ Thêm NV"). 5 flag // default backward compat S17: chỉ AllowReturnToDrafter=true. function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditLevelEntry { return { order, approverUserId, allowReturnOneLevel: false, allowReturnOneStep: false, allowReturnToAssignee: false, allowReturnToDrafter: true, allowApproverEditDetails: false, allowApproverEditBudget: false, allowApproverSkipToFinal: false, } } // Filter NV theo Phòng. Nếu Phòng = null → fallback all (chưa chọn phòng). function usersForDept(all: ApproverUser[] | undefined, deptId: string | null): ApproverUser[] { if (!all) return [] if (!deptId) return all return all.filter(u => u.departmentId === deptId) } function entriesAtLevel(step: EditStep, order: LevelOrder): EditLevelEntry[] { return step.levelEntries.filter(e => e.order === order) } // Cấp k được phép thao tác khi Cấp k-1 có ≥1 NV (gating sequential). function isLevelEnabled(step: EditStep, order: LevelOrder): boolean { if (order === 1) return step.departmentId !== null return entriesAtLevel(step, (order - 1) as LevelOrder).length > 0 } 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)), }) // Mig 25 — toggle "cho user pick lúc create phiếu" (stick/unstick) const toggleSelectable = useMutation({ mutationFn: async ({ id, isUserSelectable }: { id: string; isUserSelectable: boolean }) => api.patch(`/approval-workflows-v2/${id}/user-selectable`, { isUserSelectable }), onSuccess: (_data, vars) => { toast.success(vars.isUserSelectable ? 'Đã ghim — user có thể chọn quy trình này' : 'Đã bỏ ghim — user không thấy quy trình này') qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] }) }, onError: err => toast.error(getErrorMessage(err)), }) return (
{type.active ? ( { setCloneFrom(d); setDesignerOpen(true) }} onToggleSelectable={() => toggleSelectable.mutate({ id: type.active!.id, isUserSelectable: !type.active!.isUserSelectable, })} 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) }} onToggleSelectable={() => toggleSelectable.mutate({ id: d.id, isUserSelectable: !d.isUserSelectable, })} 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, onToggleSelectable, onDelete, }: { def: DefinitionDto isActive: boolean onClone: (d: DefinitionDto) => void onToggleSelectable: () => void onDelete: () => void }) { return (

{def.name}

{def.code} v{String(def.version).padStart(2, '0')} {isActive ? ( Đang áp dụng ) : ( Archived )} {/* Mig 25 — badge IsUserSelectable: ghim cho user pick */} {def.isUserSelectable && ( Cho user chọn )}
{def.description &&

{def.description}

}
    {def.steps.map(s => (
  1. {s.order}
    Bước {s.order} — {s.name} {s.departmentName && ( {s.departmentName} )}
    {/* Group by Order — 1 cấp có N NV */}
      {s.levels.length === 0 ? (
    • Chưa có cấp duyệt
    • ) : ( Array.from( s.levels.reduce((map, l) => { const arr = map.get(l.order) ?? [] arr.push(l) map.set(l.order, arr) return map }, new Map()).entries(), ) .sort(([a], [b]) => a - b) .map(([order, group]) => (
    • Cấp {order}
      {group.map(l => (
      {l.approverUserName ?? l.approverUserId} {l.approverEmail && ( ({l.approverEmail}) )}
      ))}
    • )) )}
  2. ))}
{/* Mig 25 — toggle stick: cho user chọn quy trình này khi tạo phiếu */}
) } // ===== 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) : [makeEmptyStep(1)]), [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 : `Quy trình ${applicableTypeLabel}`) const [description, setDescription] = useState(cloneFrom?.description ?? '') const [steps, setSteps] = useState(initialSteps) // Mig 29 (S21 t5) — 6 Allow* options MOVED: // - 5 flag F1+F3 xuống per Level slot (xem EditLevelEntry, render mỗi Level row) // - 1 flag F2 AllowDrafterSkipToFinal xuống per User (User Management page) const usersList = useQuery({ queryKey: ['users-for-approver-v2'], queryFn: async () => (await api.get<{ items: ApproverUser[] }>('/users', { params: { page: 1, pageSize: 500 }, })).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 per Bước: // - Phòng required // - Cấp 1 phải có ≥1 NV (sequential gating đảm bảo nếu C1 empty thì C2/C3 cũng empty) // - All approver thuộc đúng Phòng (defensive double-check) for (const s of steps) { if (!s.departmentId) { throw new Error(`Bước "${s.name}" chưa chọn Phòng.`) } if (entriesAtLevel(s, 1).length === 0) { throw new Error(`Bước "${s.name}" chưa có NV ở Cấp 1.`) } // Sequential gating đã enforce ở UI nhưng kiểm tra lại if (entriesAtLevel(s, 2).length === 0 && entriesAtLevel(s, 3).length > 0) { throw new Error(`Bước "${s.name}": Cấp 3 chỉ thao tác được khi Cấp 2 có NV.`) } for (const e of s.levelEntries) { if (!e.approverUserId) { throw new Error(`Bước "${s.name}": có dòng cấp chưa chọn NV.`) } const u = usersList.data?.find(x => x.id === e.approverUserId) if (u && u.departmentId !== s.departmentId) { throw new Error(`Bước "${s.name}": NV "${u.fullName}" không thuộc Phòng đã chọn.`) } } } 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, // Mỗi entry → 1 Level row. Multiple rows cùng Order = same Cấp với // N approvers (BE iterate group by Order). // Mig 29 (S21 t5) — 5 Allow* options per slot Approver. // Mig 30 (S22+5) — +AllowApproverEditBudget. // Mig 31 (S23 t1) — +AllowApproverSkipToFinal F2 storage swap per-slot. levels: s.levelEntries.map(e => ({ order: e.order, name: `Cấp ${e.order}`, approverUserId: e.approverUserId, allowReturnOneLevel: e.allowReturnOneLevel, allowReturnOneStep: e.allowReturnOneStep, allowReturnToAssignee: e.allowReturnToAssignee, allowReturnToDrafter: e.allowReturnToDrafter, allowApproverEditDetails: e.allowApproverEditDetails, allowApproverEditBudget: e.allowApproverEditBudget, allowApproverSkipToFinal: e.allowApproverSkipToFinal, })), })), }) }, 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) } 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 />