[CLAUDE] PurchaseEvaluation: nut XOA phieu o man duyet + menu 3 muc + man "Da xoa" (dot 2)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m47s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m47s
Dong ca UAT goc (Tra Sol): "lo bam sai cai goi thau, quay lai khong duoc, phai
xoa thi no moi ko co luy ke len". Dot 1 (b1bae77) da dung NEN (co F6 + lenh sua
quy trinh tai cho); dot 2 la phan nguoi dung cham vao.
- App: PeSoftDeleteFeatures.cs MOI
- DELETE /api/purchase-evaluations/{id}/by-approver — 3 rao: phase ChoDuyet
^ dung luot actor ^ matchingLevel.AllowApproverDelete (PER-NGUOI, khong g.Any)
- GET /api/purchase-evaluations/deleted — IgnoreQueryFilters() KEM .Where(IsDeleted)
(IgnoreQueryFilters GO filter chu khong DAO — thieu Where la liet ke ca phieu song)
+ tai lap khoi IDOR cua list hien hanh
- ghi vet ChangelogAction.Delete + ly do
- Api: 2 endpoint moi. 🔴 by-approver CO Y KHONG co [Authorize(Policy)] — do tren DB:
moi vai dang lam nguoi duyet (Procurement/CostControl...) deu CanDelete=0 va khong
seeder nao nang co do => gan policy la 403 voi dung nguoi can dung, ma FE gate cung
quyen nen khong thay nut khong thay loi (gotcha #44). Quyen = 3 rao handler; co F6
la admin tick dich danh tung nguoi => chat hon quyen theo vai.
- Dto: ApprovalWorkflowOptionsDto + AllowApproverDelete. Thieu field nay thi FE gate
luon sai => nut KHONG BAO GIO hien, ma BE lan FE deu build sach (reviewer bat).
- Domain/Infra: 2 menu key Pe_{code}_Approved / _Deleted (KHONG vao MenuKeys.All =>
giu 54/216) + seed tree + labelBackfill "Duyet" -> "Dang duyet" + 2 danh sach permission
- FE x2 app: nut Xoa trong PeWorkflowPanel (md5 mirror khop) + dialog ly do + Layout
regex/route cho 2 muc moi + man "Da xoa" chi-xem
- Tests: +11 (562 tong, 0 fail). T27 khoa CA LOP loi entity-co-co-ma-DTO-khong-co
(fault-inject xac nhan: go field ra => T27 do ngay)
Review: 12 FLAG, chot chan H1 (DTO thieu field) da va. Deploy KHONG co migration
nhung CO seed menu => phai restart API.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -104,7 +104,15 @@ function resolvePath(key: string): string | null {
|
||||
}
|
||||
|
||||
// Pe_<Code>_<Action> 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<string, number> = { 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_<Code> → /system/pe-workflows/<code>
|
||||
const peWfMatch = key.match(/^PeWf_(.+)$/)
|
||||
|
||||
@ -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<number | null>(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<File[]>([])
|
||||
@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{next.length > 0 && !readOnly && (
|
||||
{(next.length > 0 || canDeleteWhileApproving) && !readOnly && (
|
||||
<div>
|
||||
<Label className="text-xs">Hành động:</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
@ -501,6 +561,21 @@ export function PeWorkflowPanel({
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{/* [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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
title="Xóa phiếu này (phiếu chuyển sang mục “Đã xóa” và thôi tính vào lũy kế)"
|
||||
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" /> Xóa phiếu
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -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 && (
|
||||
<Dialog
|
||||
open
|
||||
size="sm"
|
||||
onClose={() => { setDeleteOpen(false); setDeleteReason('') }}
|
||||
title="🗑 Xóa phiếu đang duyệt"
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={() => { setDeleteOpen(false); setDeleteReason('') }}>Hủy</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteByApprover.mutate()}
|
||||
disabled={deleteByApprover.isPending}
|
||||
>
|
||||
{deleteByApprover.isPending ? 'Đang xóa…' : 'Xác nhận xóa'}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-800">
|
||||
<div className="font-semibold">Xóa phiếu “{evaluation.tenGoiThau}”?</div>
|
||||
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-[11px]">
|
||||
<li>Phiếu <strong>rời khỏi quy trình duyệt</strong> và chuyển sang mục <strong>“Đã xóa”</strong>.</li>
|
||||
<li>Phiếu <strong>thôi tính vào lũy kế</strong> ngân sách của hạng mục — đây là lý do chính của nút này.</li>
|
||||
<li>Mục “Đã xóa” hiện <strong>chỉ để xem</strong>: chưa có khôi phục. Cần lại thì phải tạo phiếu mới.</li>
|
||||
<li>Thao tác được <strong>ghi vết</strong> (ai xóa, lúc nào, lý do) vào lịch sử phiếu.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Label>Lý do xóa (nên ghi rõ)</Label>
|
||||
<Textarea
|
||||
value={deleteReason}
|
||||
onChange={e => setDeleteReason(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder="VD: chọn nhầm gói thầu — phiếu này sai hạng mục, sẽ tạo lại phiếu đúng."
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Lý do lưu vào lịch sử phiếu (tối đa 2000 ký tự). Bỏ trống vẫn xóa được, nhưng người
|
||||
sau sẽ không biết vì sao phiếu biến mất.
|
||||
</p>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{deptApprovals.length > 0 && (
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<DeptApprovalsSection rows={deptApprovals} currentPhase={evaluation.phase} />
|
||||
|
||||
@ -35,6 +35,12 @@ export function PurchaseEvaluationsListPage() {
|
||||
const [sp, setSp] = useSearchParams()
|
||||
const typeFilter = sp.get('type') ? Number(sp.get('type')) : null
|
||||
const pendingMe = sp.get('pendingMe') === '1'
|
||||
// [S155 đợt 2] Mục menu MỚI "Đã xóa" (`Pe_{code}_Deleted` → `?type=N&deleted=1`).
|
||||
// View CHỈ XEM (owner chốt (6)): KHÔNG khôi phục, KHÔNG thao tác, KHÔNG mở chi tiết —
|
||||
// `GET /purchase-evaluations/{id}` bị global filter `PurchaseEvaluationConfiguration.cs:84`
|
||||
// loại phiếu đã xóa nên bấm vào chỉ ra "Không tìm thấy phiếu". Chặn ở nguồn thay vì để
|
||||
// người dùng đâm vào ngõ cụt. Mục "Đã duyệt" KHÔNG cần cờ nào — nó chỉ là `?phase=7`.
|
||||
const deletedView = sp.get('deleted') === '1'
|
||||
const search = sp.get('q') ?? ''
|
||||
const phase = sp.get('phase') ?? ''
|
||||
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
|
||||
@ -56,8 +62,22 @@ export function PurchaseEvaluationsListPage() {
|
||||
})
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId }],
|
||||
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId, deletedView }],
|
||||
queryFn: async () => {
|
||||
// [S155 đợt 2] Nhánh "Đã xóa" phải đứng TRƯỚC `pendingMe`: 2 view loại trừ nhau, và
|
||||
// cả `/inbox` lẫn `/purchase-evaluations` đều bị global filter loại phiếu đã xóa ⇒
|
||||
// chỉ endpoint riêng `/deleted` (chỗ DUY NHẤT toàn BE dùng `IgnoreQueryFilters`) thấy.
|
||||
if (deletedView) {
|
||||
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations/deleted', {
|
||||
params: {
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
type: typeFilter ?? undefined,
|
||||
search: search || undefined,
|
||||
},
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
if (pendingMe) {
|
||||
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
||||
params: {
|
||||
@ -151,6 +171,15 @@ export function PurchaseEvaluationsListPage() {
|
||||
// đóng hẳn overlay + clear selection (về danh sách).
|
||||
const onApproved = useCallback(() => closeDetail(), [closeDetail])
|
||||
|
||||
// [S155 đợt 2] Xóa mềm xong → BẮT BUỘC đóng detail/overlay: phiếu vừa xóa không đọc lại
|
||||
// được (`GET /{id}` bị global filter loại) nên giữ overlay sẽ hiện "Không tìm thấy phiếu"
|
||||
// ngay sau một thao tác THÀNH CÔNG — trông y như lỗi. Panel đã invalidate `pe-list`;
|
||||
// gọi thêm ở đây để list nền refetch kể cả khi caller khác không invalidate.
|
||||
const onDeleted = useCallback(() => {
|
||||
closeDetail()
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
}, [closeDetail, qc])
|
||||
|
||||
const allRows = list.data?.items ?? []
|
||||
// Duyệt (pendingMe) → filter cứng client-side chỉ "Đã gửi duyệt" (Nháp/Trả lại/
|
||||
// Đã duyệt/Từ chối loại bỏ). BE /inbox hiện loose UAT có thể trả phiếu Nháp →
|
||||
@ -247,9 +276,11 @@ export function PurchaseEvaluationsListPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const headerTitle = typeFilter
|
||||
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
|
||||
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
|
||||
const headerTitle = deletedView
|
||||
? (typeFilter ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Đã xóa` : 'Duyệt NCC — Đã xóa')
|
||||
: typeFilter
|
||||
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
|
||||
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
|
||||
|
||||
// ─── S79 Focus-mode overlay (anh chốt 2026-06-19) ─────────────────────────
|
||||
// Overlay full-bleed trượt từ phải CHE menu trái + TopBar + list + inline
|
||||
@ -369,7 +400,7 @@ export function PurchaseEvaluationsListPage() {
|
||||
{/* Mig 23 — Dropdown quy trình duyệt CHỈ ở "Duyệt" (pendingMe).
|
||||
Danh sách giữ nguyên (chỉ filter trạng thái) — user feedback
|
||||
muốn Danh sách show hết phiếu không filter quy trình. */}
|
||||
{pendingMe && (
|
||||
{pendingMe && !deletedView && (
|
||||
<Select value={approvalWorkflowId} onChange={e => setParam('awId', e.target.value)}>
|
||||
<option value="">Tất cả quy trình duyệt</option>
|
||||
{approvalWorkflows.data?.map(w => (
|
||||
@ -380,8 +411,15 @@ export function PurchaseEvaluationsListPage() {
|
||||
</Select>
|
||||
)}
|
||||
{/* Duyệt (pendingMe) → filter cứng "Đã gửi duyệt", ẩn dropdown trạng thái.
|
||||
Danh sách (pendingMe=false) → giữ dropdown cho user filter mọi trạng thái. */}
|
||||
{pendingMe ? (
|
||||
Danh sách (pendingMe=false) → giữ dropdown cho user filter mọi trạng thái.
|
||||
[S155 đợt 2] "Đã xóa" → ẩn CẢ HAI: dropdown trạng thái vô nghĩa ở đây (phiếu
|
||||
giữ nguyên phase lúc bị xóa, `IsDeleted` mới là thứ phân loại). */}
|
||||
{deletedView ? (
|
||||
<div className="rounded border border-red-200 bg-red-50 px-2 py-1.5 text-[11px] text-red-700">
|
||||
🗑 <strong>Phiếu đã xóa</strong> — chỉ xem. Không khôi phục, không thao tác.
|
||||
Các phiếu ở đây <strong>không còn tính vào lũy kế</strong> ngân sách.
|
||||
</div>
|
||||
) : pendingMe ? (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
|
||||
Lọc cố định: <strong>Đã gửi duyệt</strong> (phiếu đang chờ duyệt)
|
||||
</div>
|
||||
@ -482,7 +520,12 @@ export function PurchaseEvaluationsListPage() {
|
||||
>
|
||||
<button
|
||||
onClick={() => selectRow(p.id)}
|
||||
className="block min-w-0 flex-1 px-3 py-2 pr-9 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60"
|
||||
// [S155 đợt 2] "Đã xóa" = CHỈ XEM ⇒ dòng không bấm được.
|
||||
disabled={deletedView}
|
||||
className={cn(
|
||||
'block min-w-0 flex-1 px-3 py-2 pr-9 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
|
||||
deletedView && 'cursor-default',
|
||||
)}
|
||||
>
|
||||
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@ -511,6 +554,25 @@ export function PurchaseEvaluationsListPage() {
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
{/* [S155 đợt 2] "Xóa lúc" — 🔴 API `/deleted` trả DTO DÙNG CHUNG với
|
||||
list sống ⇒ KHÔNG có `deletedAt`/`deletedBy`. Mốc dưới đây là
|
||||
`updatedAt`, mà `AuditingInterceptor` ghi ngay lượt SaveChanges của
|
||||
thao tác xóa mềm ⇒ XẤP XỈ thời điểm xóa. Nhãn "≈" để không hứa
|
||||
chính xác. TÊN người xóa API KHÔNG trả — cố ý bỏ trống thay vì đoán. */}
|
||||
{deletedView && p.updatedAt && (
|
||||
<>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span
|
||||
className="rounded bg-red-50 px-1 py-0.5 text-[10px] font-medium text-red-700"
|
||||
title="Thời điểm xóa (xấp xỉ — lấy từ mốc cập nhật cuối, ghi cùng lượt xóa mềm). Xem lịch sử phiếu để biết ai xóa và lý do."
|
||||
>
|
||||
🗑 xóa ≈ {new Date(p.updatedAt).toLocaleString('vi-VN', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(p.drafterName || p.departmentName || p.contractId) && (
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px]">
|
||||
@ -524,7 +586,10 @@ export function PurchaseEvaluationsListPage() {
|
||||
)}
|
||||
</button>
|
||||
{/* "Xem mở rộng" — icon-button góc phải, lộ rõ khi hover/focus
|
||||
hoặc dòng đang chọn; luôn bấm được (a11y aria-label + tooltip). */}
|
||||
hoặc dòng đang chọn; luôn bấm được (a11y aria-label + tooltip).
|
||||
[S155 đợt 2] ẨN HẲN ở view "Đã xóa": đó là thao tác, mà mục này
|
||||
CHỈ XEM; hơn nữa detail của phiếu đã xóa không tải được. */}
|
||||
{!deletedView && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => expandRow(p.id)}
|
||||
@ -537,6 +602,7 @@ export function PurchaseEvaluationsListPage() {
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@ -560,7 +626,13 @@ export function PurchaseEvaluationsListPage() {
|
||||
chưa mở rộng. Khi expand → overlay phủ lên (panel này vẫn ở dưới). */}
|
||||
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
||||
{!selectedId && (
|
||||
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
|
||||
<EmptyState
|
||||
icon={ClipboardCheck}
|
||||
title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'}
|
||||
description={deletedView
|
||||
? 'Mục này chỉ liệt kê phiếu đã xóa để đối chiếu. Không mở chi tiết, không khôi phục, không thao tác.'
|
||||
: 'Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây.'}
|
||||
/>
|
||||
)}
|
||||
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||
{selectedId && !detail.isLoading && !detail.data && (
|
||||
@ -581,7 +653,9 @@ export function PurchaseEvaluationsListPage() {
|
||||
{!selectedId && (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
||||
<X className="mx-auto mb-2 h-5 w-5" />
|
||||
Quy trình duyệt sẽ hiện khi chọn phiếu.
|
||||
{deletedView
|
||||
? 'Phiếu đã xóa không còn quy trình duyệt.'
|
||||
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
|
||||
</div>
|
||||
)}
|
||||
{selectedId && !isExpand && detail.data && (
|
||||
@ -589,6 +663,7 @@ export function PurchaseEvaluationsListPage() {
|
||||
evaluation={detail.data}
|
||||
readOnly={!pendingMe}
|
||||
onApproved={onApproved}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
@ -675,6 +750,7 @@ export function PurchaseEvaluationsListPage() {
|
||||
evaluation={detail.data}
|
||||
readOnly={!pendingMe}
|
||||
onApproved={onApproved}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
@ -706,7 +782,10 @@ export function PurchaseEvaluationDetailPage() {
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<PeDetailTabs evaluation={detail.data} onBack={() => navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
|
||||
<PeWorkflowPanel evaluation={detail.data} />
|
||||
{/* [S155 đợt 2] Call-site THỨ BA của panel (route fullpage mobile `/purchase-evaluations/:id`).
|
||||
Ở đây `readOnly` để MẶC ĐỊNH = false ⇒ nút Xóa CÓ THỂ hiện ⇒ bắt buộc truyền `onDeleted`,
|
||||
nếu không trang sẽ đứng lại với phiếu vừa biến mất (fetch lại là 404). */}
|
||||
<PeWorkflowPanel evaluation={detail.data} onDeleted={() => navigate('/purchase-evaluations')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -425,6 +425,10 @@ export type ApprovalWorkflowOptions = {
|
||||
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
|
||||
allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối
|
||||
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot này Duyệt = KẾT THÚC (không trình CEO)
|
||||
// [S155 F6] slot này được XÓA MỀM phiếu ChoDuyet khi tới lượt mình duyệt.
|
||||
// 🔴 PER-NGƯỜI: BE resolve slot theo `ApproverUserId == currentUser` rồi mới đọc cờ
|
||||
// (PurchaseEvaluationFeatures.cs:1083-1085) — KHÔNG phải OR-of-N cả Cấp.
|
||||
allowApproverDelete: boolean
|
||||
}
|
||||
|
||||
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE
|
||||
|
||||
Reference in New Issue
Block a user