[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
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:
@ -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 là 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">có 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 Mã / Ngưỡng CEO / cờ "Duyệt là KẾT THÚC" / "Duyệt thẳng Cấp cuối" ⇒ phải bấm <span className="font-semibold">"Tạo phiên bản mới"</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 cũ</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 và bấm <span className="font-semibold">"Sửa tại chỗ"</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>Mã 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 < 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.
|
||||
{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>Lý 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 là 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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user