# sub-d2-fe-3 — ĐỢT 2 FRONTEND, phần CHÍNH (nút Xóa màn duyệt + menu + màn "Đã xóa")

- **role:** implementer-frontend · **run:** `2026-07-27-S155-pe-delete-approver` · **lượt:** 3 (nối `sub-d2-fe-2.md`)
- **scope:** `fe-admin/src/**` + `fe-user/src/**` ONLY. KHÔNG chạm `src/Backend/**`, KHÔNG chạm `tests/`.
- **lượt trước để lại:** types ×2 app đã +`allowApproverDelete` (còn trong working tree, chưa commit).
  Mục "vá `WfView` cho fe-admin" → **lead chốt đường (c) GIỮ NGUYÊN** ⇒ bỏ hẳn khỏi phạm vi lượt này.

---

## 0. Nền đã ĐO lại lúc bắt đầu (không tin trí nhớ lượt trước)

| Đo | Kết quả |
|---|---|
| `git status` | BE lane ĐÃ LAND: `PeSoftDeleteFeatures.cs` (mới) + Controller + `MenuKeys.cs` + `DbInitializer.cs` (chưa commit) |
| `PeWorkflowPanel.tsx` md5 ×2 app | `02671be6334110028c55fe82f5e70dee` IDENTICAL, 942 dòng |
| `PurchaseEvaluationsListPage.tsx` md5 ×2 app | `99e38e167d2f745ea7628174f1d2d6bf` IDENTICAL, 712 dòng |
| `Layout.tsx` | DIVERGE cố ý — admin `ca59f56c` 284L / user `cab674f4` 415L |
| `lib/menuKeys.ts` | DIVERGE đúng 2 dòng (`Off_AttendanceReport` admin-only) |
| `PurchaseEvaluationPhase.DaDuyet` | **= 7** ⇒ `phase=7` trong hợp-đồng route KHỚP (đo ở `types/purchaseEvaluation.ts:28`) |

### Hợp-đồng BE đọc TỪ MÃ (không tin bảng trong brief)
- `[HttpDelete("{id:guid}/by-approver")]` + `[FromBody] DeleteByApproverBody? body` với `record DeleteByApproverBody(string? Reason)`
  ⇒ lý do đi trong **BODY**, không phải query ⇒ FE dùng `api.delete(url, { data: { reason } })`.
  *(Lượt trước D2 định gửi CẢ query lẫn body cho chắc; nay đo được binding thật ⇒ gửi ĐÚNG 1 đường, bỏ query thừa.)*
- `[HttpGet("deleted")]` trả `PagedResult<PurchaseEvaluationListItemDto>` — **DTO DÙNG CHUNG với list sống**.
  ⇒ **KHÔNG có** `deletedAt` / `deletedBy` / `deletedByName`. Giả định D3 lượt trước (type mới 3 field optional) **SAI** ⇒ bỏ, tái dùng thẳng `PeListItem`, **0 type mới**.
- `MenuKeys.cs` factory `Pe_{code}_Approved` · `Pe_{code}_Deleted` ĐÃ có; `DbInitializer` seed 2 mục + `labelBackfill` đổi `Duyệt`→`Đang duyệt` ĐÃ có.

---

## 1. 🔴 CHẶN THẬT — cờ F6 KHÔNG có đường ra tới FE (BE thiếu 1 member DTO)

Brief nói *"BE ĐÃ HIỆN THỰC XONG, lead tự verify"*. Đo bằng mã thì **verify đó sót đúng chỗ chết người** —
lặp lại y hệt lớp lỗi đợt 1 (BE dựng, FE không nối được).

| Đo | Kết quả |
|---|---|
| `AllowApproverDelete` trong `ApprovalWorkflowOptionsDto` (`PurchaseEvaluationDtos.cs:123-131`) | ❌ **KHÔNG CÓ** — record đúng **8** member, dừng ở `AllowApproverFinalize` |
| Chỗ dựng DTO `PurchaseEvaluationFeatures.cs:1088-1096` | ❌ truyền **8** đối số, không có `curLevel.AllowApproverDelete` |
| `AllowApproverDelete` trong `PurchaseEvaluationFeatures.cs` (toàn file) | ❌ **0 hit** |
| Kênh thay thế `approvalFlow.steps[].levels[]` | ❌ cũng **0 hit** — không có đường vòng nào |
| Chỗ ĐÃ có | ✅ Domain `ApprovalWorkflow.cs:169` · Mig 68 · Designer admin · **rào BE `PeSoftDeleteFeatures.cs:114`** |

⇒ **Hệ quả runtime:** `evaluation.currentLevelOptions.allowApproverDelete` = `undefined` ⇒ gate `=== true` false ⇒
**nút Xóa KHÔNG BAO GIỜ HIỆN**, mà `npm run build` ×2 vẫn XANH (type lượt trước khai `boolean` non-optional,
TS không kiểm được payload runtime). Đúng dạng **silent drop**.

**BE phải vá 2 dòng (tôi KHÔNG chạm `src/Backend/**`):**
1. `PurchaseEvaluationDtos.cs:131` — thêm member positional **thứ 9**: `bool AllowApproverDelete);`
2. `PurchaseEvaluationFeatures.cs:1096` — thêm đối số cuối: `curLevel.AllowApproverDelete);`

**Tôi vẫn viết FE bám ĐÚNG tên canonical `currentLevelOptions.allowApproverDelete` và ĐI TIẾP** (không dừng lượt).
Chọn **fail-closed** (`=== true`), KHÔNG fail-open (`!== false`): thà nút ẩn tới khi BE vá, còn hơn lòi nút
xoá-chứng-từ-tài-chính cho mọi người rồi ăn 403 hàng loạt.

### 1-bis. 🔴 `actorInV2Level` KHÔNG dùng được cho rào xóa (lệch rào BE) — tôi đi CHẶT HƠN brief
Brief ghi gate dùng `actorInV2Level`. Đo thì nó **rộng hơn BE**:
- `PeWorkflowPanel.tsx:99-100`: `actorInV2Level = isAdmin || v2Approvers.some(...)` ⇒ **admin bypass**.
- `PeSoftDeleteFeatures.cs:104`: `FirstOrDefault(l => l.ApproverUserId == actorId)` **KHÔNG bypass admin**, và comment
  BE nói rõ *"bê fallback `.First()` sang đây sẽ biến admin bất kỳ thành người xóa được phiếu người khác"*.
- Tệ hơn: `PurchaseEvaluationFeatures.cs:1084-1085` khi actor không match thì **fallback row ĐẦU** của Cấp
  ⇒ admin sẽ đọc cờ F6 **của người khác** ⇒ vi phạm thẳng "PER-NGƯỜI" mà owner chốt.

⇒ Dùng `actorIsCurrentApprover` = `!!currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id)`
(tập con CHẶT của `actorInV2Level`, khớp 1-1 rào BE). Khai ở đây để reviewer không tưởng tôi đọc lệch brief.

---

## 2. ✅ BƯỚC 1-2 XONG — nút + dialog + mirror

### 2.1 `PeWorkflowPanel.tsx` (viết ở fe-user → `cp` sang fe-admin)
| Chỗ sửa | Nội dung |
|---|---|
| import | +`Trash2` (lucide) · +`MenuKeys` (`@/lib/menuKeys`) · +`usePermission` (`@/hooks/usePermission`) |
| prop | +`onDeleted?: () => void` — prop **THỨ 4**. KHÔNG tái dùng `onApproved` (khác nghĩa: duyệt ≠ xóa) |
| state | `deleteOpen` · `deleteReason` |
| gate | `canDeleteWhileApproving` = `!readOnly` ∧ `phase===ChoDuyet` ∧ `actorIsCurrentApprover` ∧ `levelOptions?.allowApproverDelete === true` ∧ `can(MenuKeys.PurchaseEvaluations,'Delete')` |
| mutation | `api.delete('/purchase-evaluations/${id}/by-approver', { data: { reason } })` → invalidate `pe-list` + `pe-detail` + **`pe-budget`** rồi `onDeleted?.()` |
| render nút | **CẠNH** `next.map`, là **anh em** của output map trong cùng `flex` — KHÔNG trong map |
| dialog | `<Dialog size="sm">` riêng, `variant="danger"`, 4 gạch đầu dòng hệ quả + `<Textarea maxLength={2000}>` lý do |

**Vì sao đụng 1 token ở điều-kiện khối ngoài** `next.length > 0` → `(next.length > 0 || canDeleteWhileApproving)`:
khối "Hành động:" chỉ render khi có phase kế. Nếu `nextPhases` rỗng mà 4 rào xóa vẫn đúng thì nút sẽ bị
khối cha nuốt IM LẶNG. Nới bằng chính cờ đã qua đủ 4 rào ⇒ không mở rộng bề mặt.

**`pe-budget` có thật** (đo: 6 queryKey `pe-*` sống trong fe-user, `pe-budget` là 1) — không phải key bịa.

### 2.2 Mirror ×2 — kiểm alias TRƯỚC khi copy (không copy mù)
| Kiểm | fe-user | fe-admin |
|---|---|---|
| alias `@/*` (tsconfig.app + vite) | `./src/*` | `./src/*` — **KHỚP** |
| `hooks/usePermission.ts` | có | có (**md5 IDENTICAL**) |
| `lib/menuKeys.ts` → `PurchaseEvaluations` | `:23` | `:23` |
| `Dialog` props | `open/onClose/title/children/footer/size` | **y hệt** (file DIVERGE nhưng chỉ ở style) |
| `Button` variant `danger` | có | có (class khác, key giống) |

⇒ copy an toàn. `md5` sau copy: **`eb55781aae32c8982d9f8d1e4b9b0e26` ×2** + `git diff --no-index` exit 0.

### 2.3 Đính chính snapshot lead gửi giữa chừng (gotcha #68)
Lead báo `Trash2/MenuKeys/onDeleted/can/deleteOpen/canDeleteWhileApproving` **declared-but-never-read**.
Đó là **diagnostic CŨ chụp giữa lúc agent đang sửa**. Đo lại từ đĩa: cả 6 đều có site render, và
`tsconfig.app.json` bật **`noUnusedLocals: true` + `noUnusedParameters: true`** ⇒ `tsc --noEmit -p tsconfig.app.json`
**EXIT 0** đã là bằng chứng KHÔNG còn symbol thừa. (Chỉ tin build chạy SAU agent.)
