[CLAUDE] Workflow: sua quy trinh duyet TAI CHO + co F6 AllowApproverDelete (dot 1)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m31s

Nen cho tinh nang "xoa phieu PE o man duyet" (UAT: phieu bam sai goi thau dang
an luy ke). Dot 1 lam NEN, dot 2 moi lam nut xoa.

Vi sao phai lam nen truoc: phieu dang chay pin ApprovalWorkflowId va doc workflow
SONG theo Id do. Moi POST tao workflow moi = Id MOI, nen tick co tren ban moi
KHONG BAO GIO toi duoc phieu dang treo. Phai co lenh sua-tai-cho giu nguyen Id.

- Domain: ApprovalWorkflowLevel + AllowApproverDelete (F6, per-NGUOI) + Mig
  20260727033522_AddPeAllowApproverDelete (AddColumn bit default false, no new table)
- Application: UpdateAwDefinitionCommand + Validator + Handler
  - 2 phep thu: (T1) khung khong doi - Order tung row bat bien
                (T2) luat choi khong doi - cam CeoApprovalThreshold, AllowApproverFinalize,
                     Code, ApplicableType => 409 bao tao ban moi
  - diff theo Level.Id (CAM delete-then-insert: no FK Restrict + mo coi LevelOpinion)
  - guard 2 tang chu ky: tang 1 chu ky tren ho so CON SONG; tang 2 chu ky CON LUU
    (cha xoa mem hoac opinion xoa mem) - ca hai deu CHAN, khong xoa cung
  - purge chi khi ho so cha KHONG CON ROW; boc transaction (ExecuteDelete tu commit)
  - KHONG tang Version, KHONG dung IsActive, KHONG doi Id
- Api: PUT /api/approval-workflows-v2/{id} + [Authorize(Policy="Workflows.Create")]
- Infra: message 403 in TEN NGUOI thay GUID tran
- FE: Designer tach "Sua tai cho" (PUT) vs "Tao phien ban moi" (POST) + o tick F6
- Tests: +21 (551 tong, 0 fail) - gom F12 chung minh phieu dang chay nhan thay doi ngay

Review: 14 FLAG, 3 HIGH da va (ExecuteDelete ngoai transaction / purge xoa cung
chu ky phieu xoa mem / endpoint khong co day FE goi toi).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-27 12:36:59 +07:00
parent d745fec445
commit b1bae77c9c
13 changed files with 8604 additions and 41 deletions

View File

@ -19,7 +19,7 @@
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 { 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'
@ -52,6 +52,10 @@ type LevelDto = {
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
@ -94,6 +98,12 @@ type TypeSummaryDto = {
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ỉ
@ -108,8 +118,20 @@ type EditLevelEntry = {
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 EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null }
@ -142,11 +164,17 @@ function makeEmptyStep(stepNo: number, deptId: string | null = null): EditStep {
// 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,
@ -157,6 +185,9 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] {
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,
})),
}))
}
@ -175,6 +206,31 @@ function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditL
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,
}
}
@ -269,6 +325,9 @@ export function ApprovalWorkflowsV2Page() {
function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => void }) {
const [designerOpen, setDesignerOpen] = useState(false)
const [cloneFrom, setCloneFrom] = useState<DefinitionDto | null>(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<DefinitionDto | null>(null)
const qc = useQueryClient()
const del = useMutation({
@ -299,7 +358,8 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
<DefinitionCard
def={type.active}
isActive
onClone={d => { setCloneFrom(d); setDesignerOpen(true) }}
onClone={d => { 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,
@ -318,7 +378,7 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-700">Lịch sử versions</h3>
<Button onClick={() => { setCloneFrom(type.active); setDesignerOpen(true) }}>
<Button onClick={() => { setEditTarget(null); setCloneFrom(type.active); setDesignerOpen(true) }}>
<Plus className="h-4 w-4" />
Tạo quy trình mới
</Button>
@ -336,7 +396,11 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
key={d.id}
def={d}
isActive={false}
onClone={dd => { setCloneFrom(dd); setDesignerOpen(true) }}
onClone={dd => { 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,
@ -353,8 +417,9 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
applicableType={type.applicableType}
applicableTypeLabel={type.applicableTypeLabel}
cloneFrom={cloneFrom}
onClose={() => { setDesignerOpen(false); setCloneFrom(null) }}
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); onSaved() }}
editTarget={editTarget}
onClose={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null) }}
onSaved={() => { setDesignerOpen(false); setCloneFrom(null); setEditTarget(null); onSaved() }}
/>
)}
</div>
@ -367,12 +432,14 @@ function DefinitionCard({
def,
isActive,
onClone,
onEditInPlace,
onToggleSelectable,
onDelete,
}: {
def: DefinitionDto
isActive: boolean
onClone: (d: DefinitionDto) => void
onEditInPlace: (d: DefinitionDto) => void
onToggleSelectable: () => void
onDelete: () => void
}) {
@ -475,10 +542,26 @@ function DefinitionCard({
))}
</ol>
</div>
<div className="flex flex-col gap-1.5">
<Button variant="outline" size="sm" onClick={() => onClone(def)}>
<div className="flex w-44 shrink-0 flex-col gap-1.5">
{/* [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 đó. */}
<Button
size="sm"
onClick={() => onEditInPlace(def)}
title="Sửa ngay trên phiên bản này: thêm/bớt người trong Cấp có sẵn, đổi quyền duyệt. Giữ nguyên số phiên bản — phiếu ĐANG CHẠY áp dụng NGAY."
>
<Pencil className="h-3.5 w-3.5" />
Sửa tại chỗ
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onClone(def)}
title="Chép bản này thành phiên bản MỚI (số phiên bản tăng). Phiếu đang chạy VẪN theo bản cũ — chỉ phiếu tạo sau mới theo bản mới."
>
<Plus className="h-3.5 w-3.5" />
Tạo từ bản này
Tạo phiên bản mới
</Button>
{/* Mig 25 — toggle stick: cho user chọn quy trình này khi tạo phiếu */}
<Button
@ -506,28 +589,45 @@ 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(
() => (cloneFrom ? copyFromDefinition(cloneFrom) : [makeEmptyStep(1)]),
[cloneFrom],
() => (source ? copyFromDefinition(source) : [makeEmptyStep(1)]),
[source],
)
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 [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(
cloneFrom?.ceoApprovalThreshold != null ? String(cloneFrom.ceoApprovalThreshold) : '',
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<string | null>(null)
const [steps, setSteps] = useState<EditStep[]>(initialSteps)
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — 7 Allow* options
@ -569,12 +669,94 @@ function Designer({
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.`)
// [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<number>(src.levels.map(l => l.order))
const curOrders = new Set<number>(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,
@ -595,27 +777,37 @@ function Designer({
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,
allowApproverFinalize: e.allowApproverFinalize,
// [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: () => {
toast.success('Đã lưu quy trình mới. Version cũ đã archive.')
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 => toast.error(getErrorMessage(err)),
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
@ -635,23 +827,74 @@ function Designer({
<Dialog
open
onClose={onClose}
title={`Tạo quy trình mới — ${applicableTypeLabel}`}
title={
isEdit
? `Sửa tại chỗ — ${editTarget!.code} v${String(editTarget!.version).padStart(2, '0')} (giữ nguyên phiên bản)`
: `Tạo quy trình mới — ${applicableTypeLabel}`
}
size="lg"
footer={
<>
<Button variant="outline" onClick={onClose}>Hủy</Button>
<Button onClick={submit} disabled={save.isPending} form="aw-v2-form">
{save.isPending ? 'Đang lưu…' : 'Lưu + kích hoạt'}
{save.isPending
? 'Đang lưu…'
: isEdit ? 'Lưu thay đổi (giữ nguyên phiên bản)' : 'Lưu + kích hoạt'}
</Button>
</>
}
>
<form id="aw-v2-form" onSubmit={submit} className="space-y-4">
{/* [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 ? (
<div className="rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-[11.5px] leading-relaxed text-emerald-900">
<div className="font-semibold">Đ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.</div>
<div className="mt-1">
Lưu xong, thay đi áp dụng <span className="font-semibold">NGAY</span> cho cả những phiếu <span className="font-semibold">đang chạy</span> trên quy trình này
(kể cả phiếu đang chờ duyệt) đây cách duy nhất đ cờ quyền vừa tick tới đưc phiếu đã gửi đi.
</div>
<div className="mt-1">
Sửa được: thêm/bớt người trong Cấp <span className="font-semibold"> sẵn</span> · đ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.
<br />
Muốn thêm/bớt Bước hoặc Cấp, đi thứ tự, đi / Ngưỡng CEO / cờ &quot;Duyệt KẾT THÚC&quot; / &quot;Duyt thng Cp cui&quot; phi bm <span className="font-semibold">&quot;To phiên bn mi&quot;</span>.
</div>
</div>
) : (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-[11.5px] leading-relaxed text-amber-900">
<div className="font-semibold">Đ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ữ.</div>
<div className="mt-1">
Phiếu <span className="font-semibold">đang chạy vẫn theo bản </span> (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 bấm <span className="font-semibold">&quot;Sửa tại chỗ&quot;</span>.
</div>
</div>
)}
{/* [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 && (
<div className="flex items-start gap-2 rounded-lg border border-rose-300 bg-rose-50 px-3 py-2 text-[11.5px] leading-relaxed text-rose-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="whitespace-pre-wrap">{serverError}</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> quy trình *</Label>
<Input value={code} onChange={e => setCode(e.target.value)} required className="font-mono" />
<div className="text-[11px] text-slate-400">Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.</div>
<Input
value={code}
onChange={e => 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}
/>
<div className="text-[11px] text-slate-400">
{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.'}
</div>
</div>
<div className="space-y-1.5">
<Label>Tên hiển thị *</Label>
@ -669,12 +912,33 @@ function Designer({
value={ceoThreshold === '' ? '' : Number(ceoThreshold.replace(/[^\d]/g, '')).toLocaleString('vi-VN')}
onChange={e => setCeoThreshold(e.target.value.replace(/[^\d]/g, ''))}
placeholder="Để trống = luôn theo đủ quy trình"
// 🔴 Sửa tại chỗ KHÔNG đổi được ngưỡng: phiếu đang chờ sẽ đổi nhánh
// cần/không-cần CEO giữa chừng (BE :529 → 409). Khoá ô để người dùng
// không gõ vào rồi mới nhận lỗi. Giá trị gửi lên lấy thẳng từ bản đang lưu.
disabled={isEdit}
/>
<div className="text-[11px] leading-relaxed text-slate-400">
Gói &lt; ngưỡng: CCM duyệt xong, không cần CEO. ngưỡng: phải lên CEO.
Đ trống = luôn theo đ quy trình.
{isEdit
? 'Không đổi được khi sửa tại chỗ — đổi ngưỡng làm phiếu đang chờ nhảy nhánh cần/không-cần CEO giữa chừng. Muốn đổi: tạo phiên bản mới.'
: 'Gói < ngưỡng: CCM duyệt là xong, không cần CEO. ≥ ngưỡng: phải lên CEO. Để trống = luôn theo đủ quy trình.'}
</div>
</div>
{/* [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, để người đọc lịch sử phiếu hiểu vì sao quy
trình đổi giữa chừng (ApprovalWorkflowV2AdminFeatures.cs:802-818). */}
{isEdit && (
<div className="col-span-2 space-y-1.5">
<Label> do sửa (ghi vào lịch sử phiếu đang chạy)</Label>
<Textarea
rows={2}
maxLength={500}
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Vd: Bổ sung quyền xóa phiếu cho Trưởng phòng Mua hàng theo yêu cầu ngày 27/07."
/>
</div>
)}
</div>
{/* Mig 29 (S21 t5) — 5 Allow* F1+F3 per slot Approver.
@ -1005,6 +1269,19 @@ function Designer({
/>
<span>Duyệt KẾT THÚC quy trình (không trình tiếp CEO)</span>
</label>
{/* [F6 S155 — quyền xóa phiếu đang duyệt] PER-NGƯỜI, KHÔNG
per-Cấp: 1 dòng NV = 1 ô tick riêng. Tick cho NV nào thì
CHỈ NV đó xóa được, và chỉ khi phiếu đang Chờ duyệt ĐÚNG
lượt của họ. Cấp có 3 NV mà tick 1 ⇒ vẫn chỉ 1 người xóa. */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-rose-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverDelete}
onChange={e => updateField('allowApproverDelete', e.target.checked)}
/>
<span>Cho phép NV này XÓA phiếu khi đến lượt duyệt của mình</span>
</label>
</div>
</div>
)