// 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, Pencil, AlertTriangle } 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 allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot Duyệt = KẾT THÚC (không trình CEO) // [F6 S155] PER-NGƯỜI (per-row, KHÔNG per-Cấp): NV slot này được XÓA phiếu khi // đang ChoDuyet và đến lượt duyệt của chính mình. 1 row = 1 người ⇒ admin chỉ // đích danh, không mở cho cả Cấp. allowApproverDelete: boolean // [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role). canEditProBudget: boolean canEditCcmBudget: 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 // S69 — ngưỡng giá trị "gói CEO". Null = không áp ngưỡng (luồng đủ quy trình). // Gói < ngưỡng: CCM duyệt-final không cần CEO. ≥ ngưỡng: phải lên CEO. ceoApprovalThreshold: number | null // Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options // ALL xuống per slot Level (xem LevelDto). Admin opt-in per-Approver-slot // pattern proven 3× cumulative: F1+F3 (5 flag Mig 29) + F4 (Mig 30) + F2 // (Mig 31 refactor sang Approver scope, storage cũ Users đã drop). 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 = { // [F-7 S155] Id của dòng Cấp ĐANG TỒN TẠI trong DB. `undefined` = dòng MỚI // (admin vừa bấm "+ Thêm NV"). CHỈ dùng cho đường PUT sửa-tại-chỗ: // `UpdateAwLevelInput.Id` là `Guid?` — có Id = sửa dòng cũ, null = thêm người // vào Cấp có sẵn (ApprovalWorkflowV2AdminFeatures.cs:424-426). // Đường POST tạo version mới KHÔNG gửi field này (BE sinh Id mới). id?: string 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 allowApproverFinalize: boolean // [F6 S155] Cho phép NV này xóa phiếu khi đến lượt duyệt của mình (default false). allowApproverDelete: boolean } type EditStep = { // [F-7 S155] Id + Order GỐC của Bước trong DB — BẮT BUỘC cho PUT sửa-tại-chỗ // (`UpdateAwStepInput.Id` là Guid **không** nullable: lệnh này không có đường // tạo Bước mới) và `reqStep.Order` phải KHỚP `dbStep.Order` (:554) — nên KHÔNG // được suy ra bằng index `i+1` như đường POST (Order trong DB có thể không liền mạch). id?: string order?: number 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 => ({ // [F-7 S155] Giữ Id + Order gốc. Đường POST (tạo version mới) KHÔNG dùng 2 // field này; đường PUT (sửa tại chỗ) BẮT BUỘC có. Cùng 1 hàm nạp cho cả 2 // chế độ để không sinh nhánh nạp thứ hai — nơi 2 nhánh dễ lệch nhau. id: s.id, order: s.order, name: s.name, departmentId: s.departmentId, levelEntries: s.levels .filter(l => l.order >= 1 && l.order <= MAX_LEVELS_PER_STEP) .map(l => ({ id: l.id, 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, allowApproverFinalize: l.allowApproverFinalize ?? false, // [F6 S155] `?? false` giữ nguyên khuôn F5: bản cũ (BE chưa có cột) clone // ra vẫn hợp lệ thay vì undefined lọt vào checked → uncontrolled input. allowApproverDelete: l.allowApproverDelete ?? 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, allowApproverFinalize: false, // [F6 S155] default false — quyền xóa phiếu đang duyệt phải admin tick tay. allowApproverDelete: false, } } // [F-7 S155] 🔴 CHỐNG BẪY "silent drop": payload gửi BE là object literal LIỆT KÊ // TAY từng field (không spread `...e`) — thiếu 1 dòng thì TS vẫn build sạch, UI vẫn // tick, BE nhận default `false` ⇒ tick vô hình (đã xảy ra thật với F6, xem // sub-implementer-frontend-2.md §2.4). Từ đợt này có HAI call-site (POST + PUT) ⇒ // nguy cơ nhân đôi. Gom 9 cờ vào ĐÚNG MỘT hàm, cả 2 call-site cùng spread nó: // thêm cờ thứ 10 chỉ phải sửa 1 chỗ, không thể lệch giữa 2 đường. // - 7 cờ QUYỀN-TẠI-CHỖ (4×Return + EditDetails + EditBudget + Delete): PUT ghi được. // - 2 cờ ĐỔI-ĐƯỜNG-ĐI (SkipToFinal, Finalize): PUT chỉ ECHO — gửi lệch ⇒ 409 // (ApprovalWorkflowV2AdminFeatures.cs:580-585). Vẫn PHẢI gửi đúng giá trị đang lưu. function levelFlagsPayload(e: EditLevelEntry) { return { allowReturnOneLevel: e.allowReturnOneLevel, allowReturnOneStep: e.allowReturnOneStep, allowReturnToAssignee: e.allowReturnToAssignee, allowReturnToDrafter: e.allowReturnToDrafter, allowApproverEditDetails: e.allowApproverEditDetails, allowApproverEditBudget: e.allowApproverEditBudget, allowApproverSkipToFinal: e.allowApproverSkipToFinal, allowApproverFinalize: e.allowApproverFinalize, allowApproverDelete: e.allowApproverDelete, } } // 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) // [F-7 S155] Khác null ⇒ Designer mở ở chế độ SỬA TẠI CHỖ (PUT, giữ nguyên Id + // Version). Null ⇒ chế độ tạo version MỚI (POST) như cũ. Hai đường loại trừ nhau. const [editTarget, setEditTarget] = 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 ? ( { setEditTarget(null); setCloneFrom(d); setDesignerOpen(true) }} onEditInPlace={d => { setCloneFrom(null); setEditTarget(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 => ( { setEditTarget(null); setCloneFrom(dd); setDesignerOpen(true) }} // 🔴 Version ARCHIVED cũng PHẢI sửa-tại-chỗ được: đúng kịch bản F3 — // admin lỡ bấm "Tạo quy trình mới", bản cũ archive, nhưng phiếu đang // treo vẫn pin Id bản cũ đó. Cứu phiếu = sửa CHÍNH bản archived này. onEditInPlace={dd => { setCloneFrom(null); setEditTarget(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); setEditTarget(null) }} onSaved={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null); onSaved() }} /> )}
) } // ===== Definition card (read-only) ===== function DefinitionCard({ def, isActive, onClone, onEditInPlace, onToggleSelectable, onDelete, }: { def: DefinitionDto isActive: boolean onClone: (d: DefinitionDto) => void onEditInPlace: (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 )} {/* S69 — badge ngưỡng gói CEO (nếu có set) */} {def.ceoApprovalThreshold != null && ( Ngưỡng CEO: {def.ceoApprovalThreshold.toLocaleString('vi-VN')}đ )}
{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}) )} {/* [S76] Badge quyền nhập/điều chỉnh ngân sách (hiển thị-only, suy từ role) */} {l.canEditProBudget && ( ✎ NS PRO )} {l.canEditCcmBudget && ( ✎ NS CCM )}
      ))}
    • )) )}
  2. ))}
{/* [F-7 S155] HAI hành động TÁCH BẠCH — hệ quả khác hẳn nhau, nhãn phải nói rõ: (1) SỬA TẠI CHỖ → PUT, giữ nguyên Id + phiên bản ⇒ phiếu ĐANG CHẠY nhận NGAY. (2) TẠO PHIÊN BẢN MỚI → POST, sinh Id mới ⇒ CHỈ áp cho phiếu tạo SAU đó. */} {/* 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, editTarget, onClose, onSaved, }: { applicableType: number applicableTypeLabel: string cloneFrom: DefinitionDto | null // [F-7 S155] Khác null ⇒ chế độ SỬA TẠI CHỖ (PUT). Loại trừ nhau với cloneFrom. editTarget: DefinitionDto | null onClose: () => void onSaved: () => void }) { // [F-7 S155] `isEdit` quyết định TOÀN BỘ hành vi khác biệt của Designer: // nguồn nạp, field nào khoá, thao tác nào chặn, và gọi PUT hay POST. const isEdit = editTarget !== null const source = editTarget ?? cloneFrom const initialSteps: EditStep[] = useMemo( () => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]), [source], ) const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001' const [code, setCode] = useState(source?.code ?? defaultCode) const [name, setName] = useState(source ? source.name : `Quy trình ${applicableTypeLabel}`) const [description, setDescription] = useState(source?.description ?? '') // S69 — Ngưỡng gói CEO (nullable). String form, '' = null. Clone giữ ngưỡng cũ. // [F-7 S155] Ở chế độ sửa-tại-chỗ ô này bị KHOÁ và giá trị gửi lên KHÔNG lấy từ // state string này (xem payload PUT) — tránh mọi sai lệch do parse/format. const [ceoThreshold, setCeoThreshold] = useState( source?.ceoApprovalThreshold != null ? String(source.ceoApprovalThreshold) : '', ) // [F-7 S155] Lý do sửa → BE ghi vào changelog của CHÍNH những phiếu đang chạy // trên quy trình này (ApprovalWorkflowV2AdminFeatures.cs:802-818). Optional. const [reason, setReason] = useState('') // [F-7 S155] Thông điệp lỗi từ BE (nhất là 409 giải thích vì sao KHÔNG sửa tại // chỗ được). Toast tự tắt sau vài giây và cắt dòng — 409 của lệnh này dài và là // hướng dẫn hành động ("hãy tạo phiên bản MỚI") ⇒ phải giữ trên màn để đọc kỹ. const [serverError, setServerError] = useState(null) const [steps, setSteps] = useState(initialSteps) // Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options // ALL xuống per Level slot (xem EditLevelEntry, render inline mỗi Level row). // Admin opt-in per-Approver-slot pattern proven 3× cumulative. 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.`) } // [F-7 S155] Ràng "NV phải thuộc Phòng đã chọn" CHỈ áp cho đường tạo mới. // Sửa tại chỗ được phép đổi Phòng của Bước (BE xếp hạng AN TOÀN và KHÔNG // kiểm quan hệ NV↔Phòng — :705-706 chỉ gán DepartmentId), trong khi người // đang duyệt thì KHÔNG được đụng (đổi người mà đã có chữ ký ⇒ 409). Giữ // ràng này ở chế độ sửa sẽ chặn oan đúng thao tác BE cho phép. if (!isEdit) { 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.`) } } } } // ============ [F-7 S155] ĐƯỜNG 1 — SỬA TẠI CHỖ (PUT, giữ Id + Version) ============ if (editTarget) { // Chặn TRƯỚC ở FE những thay đổi BE sẽ trả 409, để người dùng đọc được câu // tiếng Việt nói rõ phải làm gì thay vì nhận lỗi sau khi bấm Lưu. UI đã khoá // các nút ±Bước/đổi thứ tự, đây là lớp chặn thứ hai (state vẫn có thể lệch // nếu dữ liệu nạp vào bất thường). if (steps.length !== editTarget.steps.length) { throw new Error( `Sửa tại chỗ không thêm/bớt được Bước (đang có ${editTarget.steps.length}, sau khi sửa còn ${steps.length}). Hãy dùng "Tạo phiên bản mới".`, ) } for (const s of steps) { const src = editTarget.steps.find(x => x.id === s.id) if (!s.id || !src) { throw new Error('Sửa tại chỗ không tạo được Bước mới. Hãy dùng "Tạo phiên bản mới".') } if (src.levels.some(l => l.order < 1 || l.order > MAX_LEVELS_PER_STEP)) { throw new Error( `Bước "${src.name}" có cấp ngoài phạm vi 1–${MAX_LEVELS_PER_STEP} nên màn này không hiển thị đủ. Không sửa tại chỗ được — hãy dùng "Tạo phiên bản mới".`, ) } // `src.levels[].order` từ DTO là `number`, còn `levelEntries[].order` là // `LevelOrder` (1|2|3) ⇒ ép cả hai Set về `number` để so sánh được. const srcOrders = new Set(src.levels.map(l => l.order)) const curOrders = new Set(s.levelEntries.map(e => e.order)) const lost = [...srcOrders].filter(o => !curOrders.has(o)).sort() if (lost.length > 0) { throw new Error( `Bước "${s.name}": Cấp ${lost.join(', ')} sẽ không còn ai duyệt — phiếu đang chờ ở cấp đó sẽ KẸT. Hãy giữ ít nhất 1 người ở mỗi Cấp, hoặc dùng "Tạo phiên bản mới".`, ) } const added = [...curOrders].filter(o => !srcOrders.has(o)).sort() if (added.length > 0) { throw new Error( `Bước "${s.name}": sửa tại chỗ không thêm được Cấp mới (Cấp ${added.join(', ')}). Hãy dùng "Tạo phiên bản mới".`, ) } } await api.put(`/approval-workflows-v2/${editTarget.id}`, { id: editTarget.id, name, description: description || null, // Lý do sửa → changelog phiếu đang chạy. Rỗng = không gửi. reason: reason.trim() === '' ? null : reason.trim(), // ECHO (null = BE bỏ qua, nhưng gửi đúng vẫn tốt hơn: lệch ⇒ 409 sớm). code: editTarget.code, applicableType, // 🔴 ECHO NGHIÊM (ApprovalWorkflowV2AdminFeatures.cs:529 so sánh BẰNG, // KHÔNG có "null = bỏ qua"): thiếu/sai ⇒ 409. Lấy THẲNG số từ DTO vừa // GET về — KHÔNG đi qua state string `ceoThreshold` để loại mọi rủi ro // parse/format ('' → null, dấu phân cách nghìn, làm tròn). ceoApprovalThreshold: editTarget.ceoApprovalThreshold, steps: steps.map(s => ({ id: s.id, // Order gốc, KHÔNG phải index: BE bắt `reqStep.Order == dbStep.Order` (:554). order: s.order, name: s.name, departmentId: s.departmentId, levels: s.levelEntries.map(e => ({ // null = dòng MỚI (thêm người vào Cấp có sẵn). Có Id = sửa dòng cũ. id: e.id ?? null, order: e.order, name: `Cấp ${e.order}`, approverUserId: e.approverUserId, // 9 cờ qua ĐÚNG 1 hàm dùng chung với POST — xem levelFlagsPayload(). ...levelFlagsPayload(e), })), })), }) return } // ============ ĐƯỜNG 2 — TẠO PHIÊN BẢN MỚI (POST, sinh Id mới) ============ await api.post('/approval-workflows-v2', { applicableType, code, name, description: description || null, // S69 — ngưỡng gói CEO: '' → null (không áp ngưỡng), else parse số. ceoApprovalThreshold: ceoThreshold.trim() === '' ? null : Number(ceoThreshold.replace(/[^\d]/g, '')), 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, // [F6 S155 → F-7 S155] 🔴 DÂY DỄ QUÊN NHẤT: payload liệt kê tay từng // field (KHÔNG spread `...e`) ⇒ thiếu 1 cờ thì TS vẫn build sạch, ô tick // vẫn hiện, chỉ BE không bao giờ nhận. Từ khi có call-site thứ hai (PUT), // 9 cờ gom vào levelFlagsPayload() dùng CHUNG — thêm cờ mới chỉ sửa 1 nơi. ...levelFlagsPayload(e), })), })), }) }, onSuccess: () => { setServerError(null) toast.success( isEdit ? `Đã lưu thay đổi trên ${editTarget!.code} v${editTarget!.version} — GIỮ NGUYÊN phiên bản. Các phiếu đang chạy trên quy trình này áp dụng NGAY.` : 'Đã lưu quy trình mới. Version cũ đã archive. Lưu ý: phiếu ĐANG chạy vẫn theo bản cũ.', ) onSaved() }, onError: err => { // 🔴 KHÔNG nuốt thông điệp BE. 409 của lệnh sửa-tại-chỗ giải thích chính xác // thay đổi nào phạm luật + bảo phải làm gì ("tạo phiên bản MỚI") ⇒ giữ lại // trên màn (banner) chứ không chỉ toast thoáng qua. const msg = getErrorMessage(err) setServerError(msg) toast.error(msg) }, }) function submit(e: FormEvent) { e.preventDefault() setServerError(null) 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 ( } >
{/* [F-7 S155] Banner phân biệt HAI đường — người dùng phải hiểu hệ quả khác nhau TRƯỚC khi sửa, không phải sau khi bấm Lưu. */} {isEdit ? (
Đang SỬA TẠI CHỖ phiên bản {editTarget!.code} v{String(editTarget!.version).padStart(2, '0')} — không tạo phiên bản mới.
Lưu xong, thay đổi áp dụng NGAY cho cả những phiếu đang chạy trên quy trình này (kể cả phiếu đang chờ duyệt) — đây là cách duy nhất để cờ quyền vừa tick tới được phiếu đã gửi đi.
Sửa được: thêm/bớt người trong Cấp có sẵn · đổi người duyệt · các ô quyền duyệt (gồm quyền XÓA phiếu) · tên hiển thị · Phòng của Bước.
Muốn thêm/bớt Bước hoặc Cấp, đổi thứ tự, đổi Mã / Ngưỡng CEO / cờ "Duyệt là KẾT THÚC" / "Duyệt thẳng Cấp cuối" ⇒ phải bấm "Tạo phiên bản mới".
) : (
Đang TẠO PHIÊN BẢN MỚI — số phiên bản sẽ tăng, bản hiện tại chuyển sang lưu trữ.
Phiếu đang chạy vẫn theo bản cũ (phiếu gắn chặt vào phiên bản lúc tạo). Chỉ phiếu tạo SAU khi lưu mới theo bản này. Nếu chỉ muốn thêm người / đổi quyền cho phiếu đang treo, hãy đóng lại và bấm "Sửa tại chỗ".
)} {/* [F-7 S155] Thông điệp từ máy chủ — nhất là 409 "không sửa tại chỗ được vì…". Giữ trên màn (không chỉ toast) để đọc kỹ và làm theo hướng dẫn. */} {serverError && (
{serverError}
)}
setCode(e.target.value)} required className="font-mono" // Mã là khoá của chuỗi phiên bản ⇒ sửa tại chỗ KHÔNG đổi được (BE :521 → 409). disabled={isEdit} />
{isEdit ? 'Không đổi được khi sửa tại chỗ — mã là khoá của chuỗi phiên bản.' : 'Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.'}
setName(e.target.value)} required />