[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

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:
pqhuy1987
2026-07-27 14:06:14 +07:00
parent 0aa8557650
commit 4464a464c5
15 changed files with 1633 additions and 33 deletions

View File

@ -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>
)
}