diff --git a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx index 235fb40..d4a69c0 100644 --- a/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx +++ b/fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx @@ -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(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({ @@ -299,7 +358,8 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi { 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

Lịch sử versions

- @@ -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() }} /> )}
@@ -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({ ))} -
- + {/* Mig 25 — toggle stick: cho user chọn quy trình này khi tạo phiếu */} } >
+ {/* [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" /> -
Vd QT-DN-V2-001. Version auto-tăng mỗi lần lưu.
+ 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.'} +
@@ -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} />
- 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.'}
+ + {/* [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 && ( +
+ +