diff --git a/fe-admin/src/components/Layout.tsx b/fe-admin/src/components/Layout.tsx index 2aae6d9..dbd36e6 100644 --- a/fe-admin/src/components/Layout.tsx +++ b/fe-admin/src/components/Layout.tsx @@ -104,7 +104,15 @@ function resolvePath(key: string): string | null { } // Pe__ cho module Duyệt NCC - const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending)$/) + // [S155 đợt 2 — owner restructure menu 2026-07-27] +`Approved` +`Deleted`. Đây là + // "chỗ thứ 4" của Pattern 16-bis (staticMap/resolvePath) — key seed ở BE mà quên nới + // regex thì `resolvePath` trả null ⇒ MenuLeaf DROP IM LẶNG khỏi sidebar (gotcha #50). + // 🔴 CỐ Ý KHÔNG có `WfView` ở đây (khác fe-user): fe-admin không có + // `pages/pe/WorkflowMatrixViewPage.tsx` lẫn route `/purchase-evaluations/workflow-matrix` + // ⇒ thêm vào sẽ đẻ link chết rơi vào catch-all "Trang này chưa được build". Admin đã có + // Designer (`/system/approval-workflows-v2/...`) sửa được nên bản matrix chỉ-xem là thừa. + // Lead chốt 2026-07-27 = GIỮ NGUYÊN lệch này; đừng "vá" ở lượt sau. + const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending|Approved|Deleted)$/) if (peMatch) { const [, code, action] = peMatch const PE_CODE_TO_INT: Record = { DuyetNcc: 1, DuyetNccPhuongAn: 2 } @@ -116,6 +124,11 @@ function resolvePath(key: string): string | null { // (PeDetailTabs "Sửa header" button vẫn navigate sang đó). if (action === 'Create') return `/purchase-evaluations/workspace?type=${typeInt}` if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1` + // [S155 đợt 2] 2 mục MỚI — CẢ HAI tái dùng `PurchaseEvaluationsListPage`, 0 route mới, + // 0 page mới. "Đã duyệt" chỉ là filter phase sẵn có (`DaDuyet = 7`, đã thông 3 tầng); + // "Đã xóa" là view riêng trong cùng page, gọi endpoint `/purchase-evaluations/deleted`. + if (action === 'Approved') return `/purchase-evaluations?type=${typeInt}&phase=7` + if (action === 'Deleted') return `/purchase-evaluations?type=${typeInt}&deleted=1` } // PE workflow admin leaf: PeWf_ → /system/pe-workflows/ const peWfMatch = key.match(/^PeWf_(.+)$/) diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index bebc0ad..5a4510f 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { Download, Eye, Paperclip, Upload, X } from 'lucide-react' +import { Download, Eye, Paperclip, Trash2, Upload, X } from 'lucide-react' import { Dialog } from '@/components/ui/Dialog' import { Button } from '@/components/ui/Button' import { Label } from '@/components/ui/Label' @@ -32,6 +32,7 @@ export function PeWorkflowPanel({ evaluation, readOnly = false, onApproved, + onDeleted, }: { evaluation: PeDetailBundle /** true = ẩn Chuyển tiếp + Dialog transition (dùng cho Danh sách, không dùng Duyệt). */ @@ -40,6 +41,11 @@ export function PeWorkflowPanel({ * overlay về danh sách (anh: "chọn duyệt thì nó đóng lại như ban đầu"). * KHÔNG gọi khi Trả lại / Từ chối (caller giữ overlay để xem kết quả). */ onApproved?: () => void + /** [S155 đợt 2] gọi sau khi XÓA MỀM phiếu đang duyệt thành công — caller đóng + * detail/overlay về danh sách (phiếu vừa xóa không còn đọc được: global filter + * `PurchaseEvaluationConfiguration.cs:84` loại nó khỏi `GET /{id}` ⇒ giữ overlay + * sẽ hiện "Không tìm thấy phiếu"). KHÔNG dùng lại `onApproved` vì 2 việc khác nghĩa. */ + onDeleted?: () => void }) { const [target, setTarget] = useState(null) const [comment, setComment] = useState('') @@ -59,6 +65,11 @@ export function PeWorkflowPanel({ const qc = useQueryClient() const { user: currentUser } = useAuth() const isAdmin = currentUser?.roles?.includes('Admin') ?? false + // [S155 đợt 2 — UAT Tra Sol 2026-07-27] XÓA MỀM phiếu khi ĐANG DUYỆT: "lỡ bấm sai + // cái gói thầu / quay lại không được / phải xóa thì nó mới ko có lũy kế lên". + // Dialog xác nhận RIÊNG (không trộn vào dialog transition) + ô lý do → changelog BE. + const [deleteOpen, setDeleteOpen] = useState(false) + const [deleteReason, setDeleteReason] = useState('') // S78 — người duyệt đính kèm file khi DUYỆT. File chọn (chưa upload) staged ở state, // upload trong transition.mutationFn TRƯỚC khi chuyển phase (file lỗi = không duyệt). const [approveFiles, setApproveFiles] = useState([]) @@ -102,6 +113,32 @@ export function PeWorkflowPanel({ const isV2Pending = !!evaluation.currentApproval const blockedByV2Level = isV2Pending && !actorInV2Level + // [S155 đợt 2] Rào hiển thị nút XÓA — dựng để khớp 1-1 với 3 rào BE + // (`PeSoftDeleteFeatures.cs:65 / :104 / :114`) + tầng authz độc lập (gotcha #82). + // + // 🔴 KHÔNG dùng `actorInV2Level` ở đây dù nó sẵn có: nó có `isAdmin ||` bypass, còn + // đường xóa BE CỐ Ý KHÔNG bypass admin (`:100-104` — bê fallback `.First()` sang sẽ + // biến "admin bất kỳ" thành người xóa được phiếu người khác). Tệ hơn, khi actor không + // match thì `currentLevelOptions` BE fallback về ROW ĐẦU của Cấp + // (`PurchaseEvaluationFeatures.cs:1084-1085`) ⇒ admin sẽ đọc cờ F6 CỦA NGƯỜI KHÁC, + // phá đúng ràng buộc PER-NGƯỜI owner chốt. Vậy dùng tập con CHẶT dưới đây. + const actorIsCurrentApprover = !!currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id) + // 🔴 Cờ F6 đọc từ `currentLevelOptions` — kênh DUY NHẤT per-NGƯỜI (BE resolve slot theo + // `ApproverUserId == currentUser` rồi mới đọc cờ). So `=== true` (fail-closed) CÓ CHỦ Ý: + // field vắng ⇒ ẩn nút, KHÔNG lòi nút xoá-chứng-từ-tài-chính rồi ăn 403 hàng loạt. + const canDeleteWhileApproving = + !readOnly + && evaluation.phase === PurchaseEvaluationPhase.ChoDuyet + && actorIsCurrentApprover + && levelOptions?.allowApproverDelete === true + // 🔴 CỐ Ý KHÔNG gate bằng `can(MenuKeys.PurchaseEvaluations, 'Delete')` — owner chốt + // 2026-07-27. Reviewer ĐO trên DB: mọi vai đang thật sự làm người duyệt (`Procurement`, + // `CostControl`, …) đều `CanDelete = 0`, và không seeder nào nâng cờ đó ⇒ gate bằng quyền + // theo vai sẽ **ẩn nút với đúng người có quyền**, im lặng, không lỗi (gotcha #44). + // Nguồn quyền = 3 rào ở trên, khớp 1-1 với 3 rào BE trong `PeSoftDeleteFeatures.cs`. + // Rào `allowApproverDelete` là cờ per-NGƯỜI admin tick đích danh — chặt hơn quyền theo vai. + // ⚠️ Endpoint BE cũng đã gỡ `[Authorize(Policy=...)]` cho khớp; đừng khôi phục MỘT bên. + // [Mig 54] ③ CCM duyệt-done miễn CEO: chỉ đủ điều kiện khi user là CCM (CostControl) + // workflow có ngưỡng CEO + giá gói < ngưỡng (khớp guard fail-closed BE). ① bộ chọn giá // chốt hiện khi đây là duyệt CUỐI (Cấp cuối Bước cuối đang "Current") HOẶC CCM tích done. @@ -251,6 +288,29 @@ export function PeWorkflowPanel({ onError: e => toast.error(getErrorMessage(e)), }) + // [S155 đợt 2] XÓA MỀM phiếu đang duyệt. Route CANONICAL `.../{id}/by-approver` — + // KHÁC hẳn đường xóa nháp `DELETE /purchase-evaluations/{id}` (PeDetailTabs, giữ nguyên). + // Lý do đi trong BODY: controller bind `[FromBody] DeleteByApproverBody? body` + // (`PurchaseEvaluationsController.cs`), record `(string? Reason)` ⇒ axios `data:`. + const deleteByApprover = useMutation({ + mutationFn: async () => + api.delete(`/purchase-evaluations/${evaluation.id}/by-approver`, { + data: { reason: deleteReason.trim() || null }, + }), + onSuccess: () => { + toast.success('Đã xóa phiếu — chuyển sang mục "Đã xóa", thôi tính vào lũy kế.') + setDeleteOpen(false) + setDeleteReason('') + // Phiếu rời MỌI danh sách sống (global filter) + số lũy kế đổi ⇒ invalidate cả + // pe-budget (ma trận ngân sách S133/S134 đọc lũy kế) ngoài pe-list/pe-detail. + qc.invalidateQueries({ queryKey: ['pe-list'] }) + qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] }) + qc.invalidateQueries({ queryKey: ['pe-budget'] }) + onDeleted?.() + }, + onError: e => toast.error(getErrorMessage(e)), + }) + // UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi". // BE policy đã gỡ TuChoi khỏi nextPhases; filter này = defense-in-depth FE // (BE cũ cache / deploy lệch vẫn không lòi nút). Dialog/isCancel giữ dead-safe. @@ -455,7 +515,7 @@ export function PeWorkflowPanel({ )} - {next.length > 0 && !readOnly && ( + {(next.length > 0 || canDeleteWhileApproving) && !readOnly && (
@@ -501,6 +561,21 @@ export function PeWorkflowPanel({ ) })} + {/* [S155 đợt 2] Nút XÓA — CẠNH vòng `next.map`, CỐ Ý KHÔNG nằm TRONG map: + map sinh nút theo PHASE (`workflow.nextPhases`), còn Xóa KHÔNG phải một + phase — nhét vào map sẽ phải bịa 1 phase giả. Điều kiện hiện nút ở + `canDeleteWhileApproving` (4 rào). KHÔNG đụng nút xóa NHÁP ở + `PeDetailTabs.tsx:457-472` — đó là đường khác, endpoint khác, quyền khác. */} + {canDeleteWhileApproving && ( + + )}
)} @@ -797,6 +872,51 @@ export function PeWorkflowPanel({ ) })()} + {/* [S155 đợt 2] Dialog xác nhận XÓA — RIÊNG, không trộn vào dialog transition ở trên + (transition đi `POST /transitions` với targetPhase; xóa đi `DELETE /by-approver`). + Nội dung nói thẳng HỆ QUẢ vì màn "Đã xóa" hiện là CHỈ XEM, chưa có khôi phục + (owner chốt (6)) ⇒ với người dùng đây là thao tác một chiều. */} + {deleteOpen && ( + { setDeleteOpen(false); setDeleteReason('') }} + title="🗑 Xóa phiếu đang duyệt" + footer={<> + + + } + > +
+
Xóa phiếu “{evaluation.tenGoiThau}”?
+
    +
  • Phiếu rời khỏi quy trình duyệt và chuyển sang mục “Đã xóa”.
  • +
  • Phiếu thôi tính vào lũy kế ngân sách của hạng mục — đây là lý do chính của nút này.
  • +
  • Mục “Đã xóa” hiện chỉ để xem: chưa có khôi phục. Cần lại thì phải tạo phiếu mới.
  • +
  • Thao tác được ghi vết (ai xóa, lúc nào, lý do) vào lịch sử phiếu.
  • +
+
+ +