wal: flush 20260730T0946

This commit is contained in:
pqhuy1987
2026-07-30 09:46:39 +07:00
parent c59ece09db
commit 8eba1379b2
9 changed files with 301 additions and 8 deletions

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>S162 harness — cây 4 folder GĐ</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/harness-s162.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,180 @@
// THROWAWAY — harness FD2 cho S162 (cây 4 folder GĐ). XOÁ SAU KHI CHỤP.
// Render CHÍNH các component thật (không vẽ lại markup) với cache TanStack seed
// sẵn + menu quyền seed vào localStorage ⇒ soi được UI mà không cần API/SQL.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import { AuthProvider } from '@/contexts/AuthContext'
import { PipelineStageFolders } from '@/components/pipeline/PipelineStageFolders'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
const menu = [
{ key: 'PurchaseEvaluations', label: 'Duyệt NCC', parentKey: null, order: 1, icon: null, canRead: true, canCreate: true, canUpdate: true, canDelete: true, isVisible: true, displayLabel: null, children: [] },
{ key: 'KeHoachKyKet', label: 'Kế hoạch ký kết HĐ', parentKey: null, order: 2, icon: null, canRead: true, canCreate: true, canUpdate: true, canDelete: true, isVisible: true, displayLabel: null, children: [] },
{ key: 'Contracts', label: 'Duyệt hợp đồng', parentKey: null, order: 3, icon: null, canRead: true, canCreate: true, canUpdate: true, canDelete: true, isVisible: true, displayLabel: null, children: [] },
{ key: 'HopDongCung', label: 'Hợp đồng cứng', parentKey: null, order: 4, icon: null, canRead: true, canCreate: true, canUpdate: true, canDelete: true, isVisible: true, displayLabel: null, children: [] },
]
localStorage.setItem('solution-erp-user-token', 'harness')
localStorage.setItem('solution-erp-user-user', JSON.stringify({ id: 'u1', fullName: 'Harness', email: 'h@x.vn', roles: [] }))
localStorage.setItem('solution-erp-user-menu', JSON.stringify(menu))
const pe = (i: number, over: Record<string, unknown>) => ({
id: `pe-${i}`, maPhieu: `PDG-2026-${String(i).padStart(3, '0')}`,
tenGoiThau: 'Cung cấp và lắp đặt hệ thống điện nhẹ khối A', type: 1, phase: 7,
projectId: 'prj-1', projectName: 'Khu phức hợp Sunrise Riverside', projectCode: 'SRS',
workItemId: 'wi-1', workItemName: '02 Hệ thống điện nhẹ',
selectedSupplierId: null, selectedSupplierName: null, contractId: null, slaDeadline: null,
createdAt: '2026-05-14T03:00:00Z', updatedAt: null,
drafterUserId: 'u1', drafterName: 'Nguyễn Văn Kiệt', departmentId: 'd1', departmentName: 'Phòng Mua hàng',
budgetPeriodAmount: null, expectedRemainingAmount: null, isUrgentByPro: false, isUrgentByCcm: false,
winnerSupplierNames: [], endsBeforeCeo: false, finalizeStepName: null, finalizeLevelName: null,
...over,
})
const peRows = [
pe(1, { contractId: 'ct-1' }),
pe(2, { phase: 3, tenGoiThau: 'Thi công phần thô tháp B', maPhieu: 'PDG-2026-002' }),
pe(3, { workItemId: 'wi-2', workItemName: '10 Cửa nhôm kính', tenGoiThau: 'Cửa nhôm kính mặt dựng', maPhieu: 'PDG-2026-003', createdAt: '2026-03-02T03:00:00Z' }),
pe(4, { projectId: 'prj-2', projectName: 'Nhà máy Đại An giai đoạn 2', projectCode: 'DAN', workItemId: null, workItemName: null, tenGoiThau: 'San lấp mặt bằng', maPhieu: 'PDG-2026-004', createdAt: '2025-11-20T03:00:00Z' }),
]
const khkk = [{
id: 'kh-1', maKeHoach: 'KHKK-2026-001', phase: 3, purchaseEvaluationId: 'pe-1',
peMaPhieu: 'PDG-2026-001', peTenGoiThau: 'Cung cấp và lắp đặt hệ thống điện nhẹ khối A',
projectId: 'prj-1', projectName: 'Khu phức hợp Sunrise Riverside', departmentId: null, departmentName: null,
drafterUserId: 'u1', drafterFullName: 'Nguyễn Văn Kiệt', approvalWorkflowId: null, workflowCode: null,
currentApprovalLevelOrder: null, totalProposedAmount: 4200000000, createdAt: '2026-06-01T03:00:00Z', updatedAt: null,
}]
const contracts = [{
id: 'ct-1', maHopDong: 'HĐ-TP-2026-014', tenHopDong: 'HĐ thi công hệ thống điện nhẹ khối A',
type: 1, phase: 9, supplierId: 's1', supplierName: 'Công ty TNHH Điện nhẹ Toàn Cầu',
projectId: 'prj-1', projectName: 'Khu phức hợp Sunrise Riverside', giaTri: 4150000000,
slaDeadline: null, createdAt: '2026-06-20T03:00:00Z', hasSealedCopy: true,
}]
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 600_000 } } })
qc.setQueryData(['pipeline-pe-index'], peRows)
qc.setQueryData(['pipeline-khkk-index'], khkk)
qc.setQueryData(['pipeline-contract-index'], contracts)
// Vỏ cây trang Duyệt NCC (sao markup từ PurchaseEvaluationsListPage để soi
// folder trong đúng ngữ cảnh). Leaf phiếu = rút gọn của dòng phiếu thật.
function PeTreeShell() {
return (
<aside className="flex w-[400px] flex-col overflow-hidden border-r border-slate-200 bg-white">
<div className="flex-1 overflow-y-auto">
<div className="divide-y divide-slate-100">
<details open className="group/year">
<summary className="flex cursor-pointer items-center gap-1.5 bg-slate-50 px-3 py-2 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-500 transition-transform group-open/year:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-base">📅</span>
<span className="flex-1 truncate text-[13px] font-semibold text-slate-900">Năm 2026</span>
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">3</span>
</summary>
<div className="ml-3 border-l border-slate-200">
<details open className="group/proj">
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/proj:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-sm">📁</span>
<span className="flex-1 truncate text-[12px] font-medium text-slate-700">SRS</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">3</span>
</summary>
<div className="ml-3 border-l border-slate-200">
<details open className="group/wi">
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/wi:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-sm">🧱</span>
<span className="flex-1 truncate text-[12px] font-medium text-slate-700">02 Hệ thống điện nhẹ</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">2</span>
</summary>
<PipelineStageFolders
className="ml-3 border-l border-slate-200"
currentStage={1}
stages={[
{ n: 1, leaves: [], count: 2, content: (
<ul className="divide-y divide-slate-100">
{peRows.slice(0, 2).map(p => (
<li key={p.id}>
<div className="px-3 py-2">
<div className="flex items-start justify-between gap-2">
<span className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</span>
<span className="shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800">Đã duyệt</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu}</span>
<span className="text-slate-300">·</span>
<span>14/05/2026 10:00</span>
</div>
</div>
</li>
))}
</ul>
) },
{ n: 2, leaves: [{ id: 'kh-1', primary: 'KHKK-2026-001', secondary: 'Cung cấp và lắp đặt hệ thống điện nhẹ khối A', badgeLabel: 'Đã duyệt', badgeClass: 'bg-emerald-100 text-emerald-800 border-emerald-300', onOpen: () => {} }] },
{ n: 3, leaves: [{ id: 'ct-1', primary: 'HĐ-TP-2026-014', secondary: 'HĐ thi công hệ thống điện nhẹ khối A', badgeLabel: 'Đã phát hành', badgeClass: 'bg-emerald-100 text-emerald-800', onOpen: () => {} }] },
{ n: 4, leaves: [{ id: 'ct-1', primary: 'HĐ-TP-2026-014', secondary: 'HĐ thi công hệ thống điện nhẹ khối A', badgeLabel: 'Đã lưu bản cứng', badgeClass: 'bg-emerald-100 text-emerald-800', onOpen: () => {} }] },
]}
/>
</details>
<details className="group/wi">
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 [&::-webkit-details-marker]:hidden">
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/wi:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
<span className="text-sm">🧱</span>
<span className="flex-1 truncate text-[12px] font-medium text-slate-700">10 Cửa nhôm kính</span>
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">1</span>
</summary>
</details>
</div>
</details>
</div>
</details>
</div>
</div>
</aside>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="space-y-2">
<h2 className="text-[13px] font-bold uppercase tracking-wide text-slate-500">{title}</h2>
{children}
</section>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<MemoryRouter>
<QueryClientProvider client={qc}>
<AuthProvider>
<div className="space-y-8 bg-slate-50 p-5">
<Section title="A · Trong cây trang Duyệt NCC (GĐ1 mở sẵn)">
<PeTreeShell />
</Section>
<Section title="B · Panel cây trái — trang Kế hoạch ký kết (GĐ2 mở sẵn)">
<div className="max-w-[21rem]"><PipelineTreePanel currentStage={2} /></div>
</Section>
<Section title="C · Panel cây trái — trang Hợp đồng cứng (GĐ4 mở sẵn)">
<div className="max-w-[21rem]"><PipelineTreePanel currentStage={4} /></div>
</Section>
<Section title="D · Trạng thái biên: khoá quyền / lỗi tải / rỗng">
<div className="w-[400px] border-l border-slate-200 bg-white">
<PipelineStageFolders
currentStage={1}
stages={[
{ n: 1, leaves: [{ id: 'x', primary: 'PDG-2026-009', secondary: 'Gói thầu mẫu', badgeLabel: 'Đã gửi duyệt', badgeClass: 'bg-amber-100 text-amber-800', onOpen: () => {} }] },
{ n: 2, leaves: [], locked: true },
{ n: 3, leaves: [], failed: true },
{ n: 4, leaves: [] },
]}
/>
</div>
</Section>
</div>
</AuthProvider>
</QueryClientProvider>
</MemoryRouter>
</StrictMode>,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -76,3 +76,53 @@ T1f build gate:
- fe-user `npm run build`**EXIT 0**, `✓ built in 21.91s`, `dist/assets/index-CaEssrop.js 1,629.67 kB │ gzip 411.70 kB`, css 91.82 kB.
- fe-admin `npm run build`**EXIT 0**, `✓ built in 10.15s`, `dist/assets/index-BtjKK7qJ.js 1,713.48 kB │ gzip 425.60 kB`, css 86.14 kB.
- Warning cả 2 app = PRE-EXISTING (chunk >500 kB · INEFFECTIVE_DYNAMIC_IMPORT `src/lib/realtime.ts`). 0 lỗi TS.
## MỐC 3 — FD2 visual-verify (3 vòng soi, KHÔNG ship-unseen)
Rig: authed dev-rig bị chặn (gotcha #3 — API HTTPS :5443 ⟂ vite proxy HTTP + ProtectedRoute) ⇒ dùng **static harness** như S79: mount CHÍNH component thật (không vẽ lại markup) với `queryClient.setQueryData` seed 3 cache index + menu quyền seed `localStorage:solution-erp-user-menu` (AuthProvider giữ menu cache khi `/menus/me` lỗi — `AuthContext.tsx:29-31`) + `MemoryRouter`. 4 khung: A cây trang PE · B panel GĐ2 · C panel GĐ4 · D trạng thái biên (khoá/lỗi/rỗng). Chụp `desktop-1440` + `laptop-1366` + `mobile-375`, `device_scale_factor=2`, full_page. Gotcha Vite đã né: `domcontentloaded` + `wait_for_selector` (KHÔNG networkidle) + warm-up goto 60s.
**Vòng 1 (r1) — 3 lỗi tự bắt được khi NHÌN ảnh:**
1. 🔴 Panel B/C **trông y hệt nhau**, folder giai đoạn nằm sâu 3 lớp collapsed ⇒ vào trang KHKK/HĐ-cứng KHÔNG thấy folder của mình đâu (hỏng đúng ý owner "folder trang hiện tại mở sẵn"). Kèm bug tương tác: mẹo "set rỗng = mặc định mở năm mới nhất" làm node **KHÔNG ĐÓNG ĐƯỢC** (đóng → set rỗng → mặc định bật lại).
2. 🔴 Dòng folder **khoá quyền lệch 26px** so với 3 dòng kia (thiếu khoang icon-chip) — soi rõ ở khung D mobile.
3. 🟡 Leaf: mã `font-mono 11px` bẹt ngang dòng phụ sans 11px ⇒ mất phân cấp.
**Sửa:** (1) `defaultOpen` = mở HẲN 1 NHÁNH (năm mới nhất > dự án đầu > hạng mục đầu) + state `Set|null` (`null` = chưa từng chỉnh) thay mẹo set-rỗng + đang-lọc thì ép mở hết & `toggleExpand` no-op. (2) dòng khoá giữ đúng 3 khoang (ổ khoá thay chevron + icon-chip + nhãn). (3) mã leaf `text-[12px] font-medium`.
**Vòng 2 (r2):** cả 3 PASS — B mở tới folder "Kế hoạch Hợp đồng" hiện leaf `KHKK-2026-001`, C mở "Hợp đồng cứng" hiện `HĐ-TP-2026-014 · Đã lưu bản cứng` ⇒ B/C giờ KHÁC nhau rõ, chứng minh `currentStage` ăn thật. Còn 1 điểm tự bắt: nhãn muted `text-slate-400` (#94a3b8) = **2.8:1 < sàn AA 4.5:1** mà folder rỗng VẪN bấm mở được (chữ đang-hoạt-động) → đổi `text-slate-500` (4.8:1); chip "Sắp"/icon giữ slate-400 (trạng thái trơ + token verbatim dải strip).
**Vòng 3 (r3):** chụp lại sau sửa contrast — PASS, không lệch layout.
**Rubric FD4:** hierarchy ✓ (5 cấp: Năm 13px semibold → Dự án/Hạng mục 12px slate-700 → folder 12px accent → leaf mã 12px mono + phụ 11px slate-500) · spacing ✓ (py-1.5 dòng cây / py-2 leaf, rail `ml-3 border-l` mỗi cấp) · typography ✓ · contrast ✓ (brand-800 13.0:1 · violet-700 7.4:1 · amberx-700 4.9:1 · teal-700 5.6:1 · muted slate-500 4.8:1) · states ✓ (hover/focus-visible ring brand + loading skeleton + empty + error + locked) · responsive ✓ (1440/1366/375; `<lg` panel thu về nút "Hiện cây", bảng không bị đẩy) · detail ✓ (4 dòng folder thẳng hàng tuyệt đối sau fix 2).
Ảnh giữ lại: `harness-shot-r3-desktop-1440.png` + `harness-shot-r3-mobile-375.png` (cùng thư mục run). Harness gốc archive `harness-s162.tsx.txt` + `harness-s162.html.txt`, **đã XÓA khỏi cây src cả 2 app** (verify `ls` = No such file).
## MỐC 4 — RE-MIRROR + BUILD GATE CUỐI
Bảng SHA MỚI (bảng ở MỐC 2 đã thối vì 3 fix — 2 file đầu đổi hash), đo sau khi copy lại đủ 6 file:
| file | sha256[0:16] | verdict |
|---|---|---|
| components/pipeline/PipelineStageFolders.tsx | 05c93c3619a9cfec | SHA-PAIR |
| components/pipeline/PipelineTreePanel.tsx | 76e5dafb239e402a | SHA-PAIR |
| hooks/usePipelineStages.ts | e762d5eb9c3a057a | SHA-PAIR |
| pages/pe/PurchaseEvaluationsListPage.tsx | 1d8d8f75c7d2cc14 | SHA-PAIR |
| pages/khkk/KhkkListPage.tsx | 40a650bf6964ab4b | SHA-PAIR |
| pages/contracts/HardCopiesPage.tsx | d30a911e7483f96a | SHA-PAIR |
Build gate CUỐI (sau khi đã xoá harness khỏi src):
- fe-user: `npx tsc -b --force`**EXIT 0**; `npm run build`**EXIT 0**, `dist/assets/index-JKWooGXp.js 1,630.09 kB │ gzip 411.78 kB` · css 91.82 kB · `✓ built in 472ms`.
- fe-admin: `npx tsc -b --force`**EXIT 0**; `npm run build`**EXIT 0**, `dist/assets/index-DHQO_qL3.js 1,713.91 kB │ gzip 425.69 kB` · css 86.14 kB · `✓ built in 520ms`.
- Warning 2 app = PRE-EXISTING (chunk >500 kB · INEFFECTIVE_DYNAMIC_IMPORT realtime.ts). 0 lỗi TS.
- `git status --porcelain`: đúng 12 file sản phẩm (6 × 2 app) + 4 artifact thư mục run. KHÔNG commit (em main commit).
## 2 lần lead báo "mày chết" — ĐÍNH CHÍNH bằng đĩa
Cả 2 lần đều là **stale IDE diagnostic** (bài `feedback_stale_diagnostic_background_agent`, gotcha #68), KHÔNG phải #53:
- Lần 1 (báo `:625 ')' expected` + 4 symbol unused): đo lại `npx tsc -b --force` ngay lúc đó = **EXIT 0**, vùng `:620-627` trên đĩa khâu đúng.
- Lần 2 (báo KhkkListPage `:114/:138/:141` + HardCopiesPage `:97/:116/:250-264` rách JSX): đọc thẳng 3 vùng đó trên đĩa = nguyên vẹn, `tsc -b --force` = **EXIT 0**.
⇒ Diagnostic chụp GIỮA lúc agent đang Edit nhiều file. Chỉ tin build chạy SAU khi agent dừng tay.
## MỐC 5 — MEMORY + đóng việc
- `.claude/agent-memory/frontend-designer/MEMORY.md`: +1 entry S162 (Activity log). L1 vượt ngân sách (19.6KB > sàn 17.1KB) ⇒ nén theo đúng khuôn archive sẵn có: chuyển VERBATIM 3 mục 06/2026 (S79 pe-decouple · S69 3-shared-UI · S69 OfficeDashboardPage) sang `archive/2026-06.md` + để lại dòng trỏ `substring:"…"`; cập nhật `archive/_INDEX.md` (5→8 entry). Sau nén **14.6KB**, integrity check 7/7 khoá value_protect còn nguyên (S162 · S159 · EmployeesListPage · #53 · Tailwind-v4-accent-stop · authed-rig-#3 · lucide-alias-dodge).
- KHÔNG commit (em main commit, scope `FE-User`/`FE-Admin`). KHÔNG đụng BE/menu/seeder/strip/tests.
### TỰ ĐÁNH GIÁ CÒN HỞ (lead cần biết)
1. **Chưa soi được trên PROD/authed thật** — mọi ảnh là harness tĩnh với dữ liệu giả. Nút cần nghiệm thu khi deploy: cây có ra đúng gói thầu thật không, và `pageSize 200` có đủ cho lượng HĐ/KHKK thật không (>200 thì leaf GĐ3 rơi về dạng tối giản, GĐ4 mất cờ bản cứng).
2. **GĐ3/GĐ4 nối qua `pe.contractId`** — phiếu PE chưa gắn HĐ thì 2 folder đó rỗng dù HĐ có tồn tại (đi đường KHKK→HĐ mà không ghi ngược `contractId` về phiếu là cây đứt). Đây là giới hạn DỮ LIỆU, không phải UI.
3. Trang GĐ3 (cụm HĐ 7 loại) CHƯA gắn panel — đúng spec đợt-1 (để đợt-2 sau khi owner xem prod).

View File

@ -0,0 +1,38 @@
# sub-reviewer-1 — T2 review đối-kháng cây 4-folder GĐ (S162, 2026-07-30)
Ghi-đĩa-trong-lúc-làm (chống #53). Append theo từng mục soi. Verdict cuối ở cuối file.
## Phạm vi đo (từ đĩa, `git status --porcelain` + `git diff --stat`)
- 6 file tracked M: `fe-{user,admin}/src/pages/pe/PurchaseEvaluationsListPage.tsx` · `.../khkk/KhkkListPage.tsx` · `.../contracts/HardCopiesPage.tsx` — tổng +168/16.
- 6 file MỚI untracked: `fe-{user,admin}/src/components/pipeline/{PipelineStageFolders,PipelineTreePanel}.tsx` + `fe-{user,admin}/src/hooks/usePipelineStages.ts`.
- Ngoài src: memory frontend-designer (MEMORY.md + archive/2026-06.md + archive/_INDEX.md), `.session-counter.json`, run-folder artifacts (2 png + 2 harness .txt + sub-frontend-designer-0.md).
## MỤC 1 — Claim "khối `<li>` phiếu giữ nguyên từng byte" — ĐẠT
`git diff -- .../pe/PurchaseEvaluationsListPage.tsx` (cả 2 app, diff IDENTICAL):
- Hunk A `@@ -29,6 +29,8` — +2 import. 0 xoá.
- Hunk B `@@ -47,6 +49,14` — +8 dòng (6 comment + `usePipelineStages()` + `filterActive`). 0 xoá.
- Hunk C `@@ -507,7 +517,12` — xoá ĐÚNG 1 dòng `<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">`.
- Hunk D `@@ -607,6 +622,7` — +1 dòng `) })} />`. 0 xoá.
⇒ Tổng xoá = 1 dòng. Khối `<li>` KHÔNG có dòng `-` nào ⇒ CLAIM ĐỨNG. Class cũ không mất: `ml-3 border-l border-slate-200` → prop `className` wrapper; `divide-y divide-slate-100` giữ ở `<ul>` trong.
## MỤC 6 — Biên giới — ĐẠT
- `git diff --name-only` (tracked): 0 file `src/Backend`, 0 `tests/`, 0 seeder/menu (#84), 0 `PePipelineStrip.tsx`. Chỉ 6 file FE + memory/run-artifact ngoài src.
- `grep -rn "export default"` trong 6 file mới ⇒ NONE (named export only, đúng §3.9).
- Nhãn VERBATIM `PipelineStageFolders.tsx:22-27`: `1:'Duyệt NCC'` · `2:'Kế hoạch Hợp đồng'` · `3:'Duyệt Hợp đồng'` · `4:'Hợp đồng cứng'` — khớp lời owner từng chữ.
- Mirror SHA256 6/6 PAIR (tự đo lại, không tin bảng designer):
PipelineStageFolders `05c93c3619a9cfec` · PipelineTreePanel `76e5dafb239e402a` · usePipelineStages `e762d5eb9c3a057a` · PE page `1d8d8f75c7d2cc14` · Khkk page `40a650bf6964ab4b` · HardCopies page `d30a911e7483f96a`.
- `types/contracts.ts` lệch 2 app CHỈ ở giá trị `ContractPhaseColor` (cùng bộ key 1-10,99) ⇒ hook SHA-chung import an toàn, `badgeClass` undefined vẫn có fallback `PipelineStageFolders.tsx:189`.
## MỤC 3 — Permission-gate 2 tầng — 1 LỖ THẬT (latent) + 1 điểm sai luận-cứ
Key FE (`usePipelineStages.ts:41-44`) vs BE:
- `Khkk_List`/`Khkk_G1`/`KeHoachKyKet` — cả 3 tồn tại thật (`DbInitializer.cs:2184-2188` KhkkKeys + `MenuKeys.cs:41`).
- `Hdc_<7 code>` + `HopDongCung` — tồn tại thật (`DbInitializer.cs:1868/2191`).
- `Ct_<code>_List` + `Contracts` — tồn tại thật.
- `can(k)` = `node.canRead` (`usePermission.ts:20`), findNode không thấy ⇒ false ⇒ OR-nhiều-key hợp lệ về mặt đọc cây.
🔴 **F: OR-nhiều-key ĐÁ NHAU với policy API cho GĐ2.** `ContractSigningPlansController.cs:36` = `[Authorize(Policy="KeHoachKyKet.Read")]`, và `MenuPermissionHandler.cs:40` so khớp **CHÍNH XÁC** `p.MenuKey == req.MenuKey` (KHÔNG kế thừa, KHÔNG OR con). ⇒ role có `Khkk_List=1``KeHoachKyKet=0` sẽ: FE cho `canKhkk=true` → gọi API → **403**`khkkFailed` → GĐ2 hiện "—" dưới MỌI gói thầu, và claim "0 rác 403" vỡ. Đo Dev DB: 13/13 role đều có `Contracts=1``KeHoachKyKet=1` ⇒ HIỆN CHƯA vỡ; prod chưa đo được (chỉ localhost+secret). Là lỗ THIẾT KẾ chờ 1 lần chỉnh ma trận quyền.
🟡 luận-cứ trong comment `usePipelineStages.ts:10-13` ("gate phải OR nhiều key vì root có thể canRead=false") đúng cho TẦNG HIỂN THỊ nhưng sai đích cho tầng API: query nào cũng nên gate bằng ĐÚNG key policy của endpoint (`KeHoachKyKet` cho `/contract-signing-plans`).
🟢 `/contracts` KHÔNG có policy action-level (`ContractsController.cs:16-17`, chỉ `[Authorize]` trần class) ⇒ nhánh `enabled: canContract || canHardCopy` KHÔNG sinh 403. Đổi lại: đây là bare-`[Authorize]` sẵn có của hệ (#82), diff này không làm tệ thêm nhưng có nới hiển-thị: user chỉ được cấp `Hdc_ThauPhu` vẫn thấy leaf GĐ4 của MỌI loại HĐ (`usePipelineStages.ts:211`) vì gate là OR-toàn-nhóm chứ không theo `c.type`.
🟢 Không quyền ⇒ `enabled:false` (`:77`, `:91`, `PipelineTreePanel.tsx:108`) ⇒ KHÔNG gọi API; folder khoá render nhánh riêng KHÔNG `<details>` (`PipelineStageFolders.tsx:99-113`). Fetch lỗi ⇒ `failed` ⇒ badge "—" + body "Không tải được…" (`:140-156`) ⇒ cây không vỡ. ĐẠT.