Files
solution-erp/.claude/workflows/runs/2026-08-05-S174-khkk-style-audit/sub-frontend-designer-L4.md
2026-08-05 16:27:07 +07:00

32 KiB
Raw Blame History

sub-frontend-designer-L4 — LĂNG-KÍNH: CÂY TOÀN TRÌNH (Pipeline Tree)

Run: 2026-08-05-S174-khkk-style-audit · Lane L4 (ensemble /fable-clone frontend-designer, 5 lane cùng vai). Scope 3 file: fe-admin/src/components/pipeline/PipelineTreePanel.tsx · fe-admin/src/components/pipeline/PipelineStageFolders.tsx · fe-admin/src/hooks/usePipelineStages.ts. Owner-item BẮT BUỘC: (O2) badge chữ "SẮP" → "Bỏ hết đi đổi thành 0" · (O6) card phiếu PE trong cây văng sang /purchase-evaluations/... → giữ tại-chỗ chỉ-xem. Ghi APPEND-as-I-read (không gom cuối). Frame lượt 1.


0. Khung rỗng (ghi TRƯỚC khi đọc) — checklist tự-ràng-buộc

  • A. Inventory 3 file: LOC, export, prop-surface, ai gọi ai.
  • B. Host-map: liệt ĐỦ 6 host của PipelineTreePanel (file:line + prop truyền).
  • C. (O2) Truy chữ "SẮP": sinh ở file:line nào, điều kiện bật, GĐ nào đúng-nghĩa / GĐ nào stale.
  • D. (O2) Đề xuất đổi sang "0": đụng gì (state/type/style), host nào đổi hành-vi.
  • E. (O6) Truy navigate( + onOpenKhkk + mọi điều-hướng trong 3 file → đường đang đi.
  • F. (O6) Đề xuất callback-hoá mirror stage2Options.onOpenKhkk, ×6 host.
  • G. Style/UX delta KHKK (GĐ2) vs Duyệt NCC (GĐ1 golive = CHUẨN) trong lăng-kính cây.
  • H. Spec thi-hành-được (diff-level) cho Opus 5 MAX.
  • I. Rủi-ro / cái KHÔNG được đụng.

Số đo nền (chạy 2026-08-05, trước mọi patch)

Phép đo Kết quả
sha256sum 3 cặp file mirror 3/3 KHỚPPipelineTreePanel 3f5ffc6b…27cba2 · PipelineStageFolders bed52f39…6de936 · usePipelineStages 55770e39…2f6f4 ⇒ luật MIRROR ×2 app đang được giữ, patch PHẢI giữ tiếp
grep -rn "SoonChip" (2 app) 8 hit (4/app: :99 def + :195/:233/:273 gọi)
grep -c "navigate(\|navigate('" usePipelineStages.ts` 7 dòng = 6 lời gọi + 1 comment (:182) — cả 2 app y hệt
grep -c navigate PipelineStageFolders.tsx 0 (kể cả comment)
grep -c navigate PipelineTreePanel.tsx 1 — và là COMMENT :97, 0 lời gọi

A. Inventory 3 file

File (fe-admin) LOC Vai Router?
components/pipeline/PipelineTreePanel.tsx 352 Panel cây trái: query pipeline-pe-index (200 phiếu) → buildTree Năm>Dự án>Hạng mục → mỗi hạng mục render <PipelineStageFolders> (:330-335) KHÔNG (0 useNavigate)
components/pipeline/PipelineStageFolders.tsx 334 THUẦN TRÌNH BÀY: vẽ 4 folder GĐ + ngăn nhóm + leaf. Không fetch, không route KHÔNG
hooks/usePipelineStages.ts 404 Nguồn dữ liệu + TOÀN BỘ điều-hướng (useNavigate khai :200) CÓ — nhà duy nhất của navigate(
  • fe-user MIRROR: LOC 352/334/404 y hệt; 3 header đều khai "File MIRROR SHA256 identical giữa 2 app — sửa 1 bên PHẢI copy sang bên kia" (PipelineStageFolders.tsx:10, usePipelineStages.ts:3, PipelineTreePanel.tsx:6) ⇒ mọi patch = ×2 app, nghiệm thu bằng SHA256 từng cặp.
  • Kiến trúc đã sẵn sàng cho O6: navigate KHÔNG rải trong 2 file trình-bày ⇒ callback-hoá chỉ đụng 1 file logic + prop-surface.

B. Host-map — ĐỦ 6 host của PipelineTreePanel

# Host (file:line render) currentStage Prop đang truyền Ghi chú
H1 fe-admin/src/components/khkk/KhkkListPanel.tsx:292 2 stage2Options (content+count+onOpenKhkk), hideHeader, bodyMaxHClass Màn KHKK 3-panel — host của cả O2 lẫn O6
H2 fe-user/src/components/khkk/KhkkListPanel.tsx:292 2 (mirror H1) mirror
H3 fe-admin/src/pages/contracts/ContractsListPage.tsx:249 3 bodyMaxHClass="max-h-[28rem] lg:max-h-none", render CÓ ĐIỀU KIỆN {!selectedId && …} HĐ (admin)
H4 fe-user/src/pages/contracts/MyContractsPage.tsx:225 3 như H3 HĐ (user)
H5 fe-admin/src/pages/contracts/HardCopiesPage.tsx:117 4 chỉ currentStage Bản cứng (admin)
H6 fe-user/src/pages/contracts/HardCopiesPage.tsx:117 4 như H5 Bản cứng (user)

+2 call-site KHÔNG qua Panel (đếm cho O2 vì badge sống trong PipelineStageFolders):

  • H7 fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx:524 — dùng THẲNG <PipelineStageFolders currentStage={1} … stages={buildStages(wg.items, { stage1Content: … })}> (cây riêng trang Duyệt NCC; PipelineTreePanel.tsx:8-10 khai rõ KHÔNG đụng cây đó).
  • H8 fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx:524 — mirror.

Host đã GỠ (đừng đếm nhầm): fe-user/src/pages/InboxPage.tsx:341[S174 owner] GỠ PipelineTreePanel khỏi Hộp thư. Không còn render.

O2 chạm 8 điểm render (H1-H8); O6 chạm 8 điểm nhưng chỉ H1/H2 (+H7/H8 nếu nhận nhánh đối-xứng) ĐỔI HÀNH VI — chi tiết §D/§F.

C. (O2) Truy chữ "SẮP" — sinh ở ĐÂU, bật khi NÀO

Nguồn DUY NHẤT: fe-admin/src/components/pipeline/PipelineStageFolders.tsx:99-107 — hàm SoonChip():

<span className="shrink-0 rounded bg-slate-100 px-1 text-[9px] font-semibold uppercase tracking-wide text-slate-400">
  Sắp
</span>

Chuỗi trong code là Sắp; CSS uppercase biến nó thành "SẮP" trên màn (comment :100-101 khai đúng vậy — token bê verbatim từ components/pe/PePipelineStrip.tsx:50-52).

3 chỗ gọi — 3 NGỮ NGHĨA KHÁC NHAU bị gộp vào cùng 1 chip (gốc của lỗi owner thấy) — vị trí ĐO bằng grep, không ước:

# Call-site Điều kiện bật Nghĩa THẬT "SẮP" đúng không?
S-a :273 (StageFolder summary, nhánh cuối của ternary :263-274) !stage.failed && count === 0; count = stageCount() (:95-97) Folder GĐ này rỗng CHO GÓI THẦU NÀY SAI — module đã live, chỉ là gói chưa có bản ghi
S-b :195 (GroupFolder summary) group.leaves.length === 0 Ngăn nhóm duyệt N1-N8 rỗng SAI — thân ngăn ngay dưới (:200) đã ghi "Chưa có phiếu ở nhóm này." ⇒ chip nói ngược nội dung
S-c :233 (nhánh stage.locked) stage.locked = !linkage.canX (usePipelineStages.ts:298/386/394) Không có QUYỀN xem GĐ này (và KHÔNG fetch) SAI NẶNG NHẤT — "sắp có" ≠ "anh không được xem"; icon Lock :228 + title :226 đã nói đúng rồi

GĐ nào còn đúng nghĩa "sắp" (chưa build)? → KHÔNG CÒN GĐ NÀO.

  • GĐ1 Duyệt NCC: live (PE feature-complete).
  • GĐ2 Kế hoạch Hợp đồng: live từ Mig 69 AddContractSigningPlans (S161)usePipelineStages.ts:96 gọi thật /contract-signing-plans.
  • GĐ3 Duyệt Hợp đồng: live — :112 gọi /contracts.
  • GĐ4 Hợp đồng cứng: live — :369 đọc c.hasSealedCopy, :378 mở /hard-copies?type=. ⇒ cả 4 folder đều fetch API THẬT. Chip "Sắp" là di-tích ngữ-nghĩa bê từ PePipelineStrip (thời dải strip còn GĐ chưa dựng) — nay 100% ca hiển thị của nó đều là thông tin sai. Lệnh owner "Bỏ hết đi đổi thành 0"sửa đúng nghiệp vụ, không phải chuyện thẩm mỹ.

Có host nào ĐANG DỰA vào chữ "SẮP"? → KHÔNG (đo ở D5).

D. (O2) Đề xuất "0" — đổi gì, đụng gì

D1. Patch (áp cho PipelineStageFolders.tsx, MIRROR ×2 app)

(1) XOÁ SoonChip (:99-107) — thay bằng 2 chip có ngữ-nghĩa tách bạch:

/** Số phiếu thật. `tabular-nums`: 4 folder xếp chồng, 1→2 chữ số không được giật cột. */
function CountChip({ n, style, muted }: { n: number; style: StageStyle; muted?: boolean }) {
  return (
    <span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium tabular-nums',
      muted ? 'bg-slate-100 text-slate-600' : style.count)}>{n}</span>
  )
}
/** Số KHÔNG XÁC ĐỊNH (lỗi tải / không có quyền) — KHÔNG được hiển thị là 0. */
function UnknownChip({ title }: { title: string }) {
  return <span title={title} className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-600"></span>
}

(2) 3 call-site đổi như sau:

Call-site Trước Sau Lý do
:270-274 StageFolder count>0 ? chip : <SoonChip/> <CountChip n={count} style={style} muted={count===0}/> ĐÚNG lệnh owner: rỗng ⇒ 0
:192-196 GroupFolder count>0 ? chip : <SoonChip/> <CountChip n={count} style={style} muted={count===0}/> ngăn N1-N8 rỗng ⇒ 0 (thân ngăn :200 đã ghi "Chưa có phiếu ở nhóm này." ⇒ hết mâu thuẫn)
:233 nhánh locked <SoonChip/> <UnknownChip title="Bạn chưa có quyền xem giai đoạn này"/> 🔴 CỐ Ý KHÔNG theo chữ "0" — xem D2
:263-269 nhánh failed chip "—" text-slate-400 <UnknownChip title="Không tải được dữ liệu giai đoạn này"/> gộp về 1 chip + vá contrast (D3)

D2. 🔴 ĐIỂM PHẢI HỎI OWNER 1 DÒNG (đừng tự quyết im lặng)

Ca locked (:222-236) không fetch (usePipelineStages.ts:102/118 enabled: theo quyền) ⇒ leaves rỗng vì không được xem, KHÔNG phải vì không có phiếu. Ghi "0" ở đây = nói dối dữ liệu: người dùng đọc "gói này chưa có Hợp đồng" trong khi thật ra có mà họ không được thấy. Đề xuất giữ "—" (đúng một nghĩa với failed = số không xác định), icon Lock + title đã sẵn ở :226-228. → Câu hỏi cho owner: "'Bỏ hết SẮP đổi thành 0' — riêng folder BỊ KHOÁ QUYỀN em để dấu '—' (không biết số) thay vì 0, được không anh?". Nếu owner vẫn muốn 0 tuyệt đối ⇒ đổi 1 dòng, nhưng phải khai đây là con số không có thật.

D3. Quality ride-along ĐO ĐƯỢC (FD5 contrast floor)

Chip Màu Tỉ lệ tương phản (tính tay sRGB/WCAG 2.1) Verdict
"SẮP" hiện tại text-slate-400 #94A3B8 / bg-slate-100 #F1F5F9 2.38:1 FAIL AA (sàn 4.5:1 cho text nhỏ)
"—" failed hiện tại (:265) slate-400 / slate-100 2.38:1 FAIL AA
Đề xuất 0 rỗng text-slate-600 #475569 / slate-100 6.91:1 PASS AA (dư cả AAA text thường)
(loại) text-slate-500 #64748B / slate-100 4.34:1 vẫn thiếu 0.16 — đừng dùng slate-500
⇒ Đổi "SẮP"→"0" là dịp hợp lệ để vá luôn 2 chip FAIL AA đang có, KHÔNG phải scope-creep: cùng đúng 1 vùng code, cùng 1 lần soi.

D4. Đụng gì thêm — comment/lý-lẽ trỏ vào thứ sắp biến mất (6 chỗ, PHẢI sửa cùng lượt)

PipelineStageFolders.tsx:77-79 · :82 · :219 · :259 · usePipelineStages.ts:8 · :254-256. ⚠️ Riêng :77-79 + usePipelineStages.ts:254-256 dùng chip "Sắp" làm tiền lệ biện minh cho quyết định "hiện đủ 8 ngăn kể cả rỗng". Giữ quyết định, đổi lý-lẽ: khung 8 nhóm hiện đủ vì người dùng cần thấy chỗ nào trống để bấm vào lập phiếu — không liên quan chữ trên chip.

D5. Có host nào dựa vào chữ "SẮP"? → KHÔNG (đo, không đoán)

  • SoonChip không export (:99) ⇒ 0 import ngoài. grep -rn "SoonChip" fe-admin/src fe-user/src ⇒ chỉ 2 file mirror này.
  • Không prop/host nào điều khiển nó; nó suy nội bộ từ count/locked.
  • Không route/test/e2e nào bắt chuỗi.
  • ⚠️ False-positive KHÔNG ĐƯỢC ĐỤNG: fe-admin/src/pages/DashboardPage.tsx:113 "Sắp quá hạn (24h)" — nghĩa hoàn toàn khác (deadline), không thuộc lớp lỗi này.

D6. 🔴 SIBLING-INSTANCE cùng lớp lỗi — NGOÀI 3-file lens, escalate em main

fe-admin|fe-user/src/components/pe/PePipelineStrip.tsx (dải strip trên đầu detail phiếu PE):

  • :13-18 STAGES hardcode soon: true cho GĐ2 "Kế hoạch ký kết HĐ"GĐ4 "Hợp đồng cứng";
  • :37 title='Sắp triển khai'; :49-52 chip Sắp (cùng token slate-400/slate-100 ⇒ cũng 2.38:1);
  • :7-9 comment khẳng định "GĐ2/GĐ4 chưa có module" + "mọi stage không-active đều KHÔNG click được — hiển thị lộ trình, không giả link". Nay SAI cả 3: GĐ2 live từ Mig 69 (S161), GĐ4 live (hasSealedCopy + /hard-copies), và cầu PE→KHKK→HĐ đã nối (usePipelineStages.ts:322-328). Owner nói "bỏ HẾT" — nếu chỉ vá cây mà để strip nguyên, người dùng mở phiếu PE vẫn thấy "SẮP" ⇒ vá nửa vời trông y hệt chưa vá. Đề nghị em main giao lane khác (chủ file components/pe/) xử cùng đợt.

D7. Ảnh hưởng lên 6 host (+2 call-site trực tiếp) — O2

Host Đổi hành vi? Cụ thể
H1 KHKK admin KhkkListPanel.tsx:292 CÓ (thị giác) GĐ2 dùng contentkhông có ngăn; folder GĐ1/3/4 rỗng: SẮP→0. Không đổi luồng bấm
H2 KHKK user CÓ (thị giác) như H1
H3 HĐ admin ContractsListPage.tsx:249 CÓ (thị giác) GĐ2 hiện 8 ngăn ⇒ ngăn rỗng SẮP→0; folder rỗng SẮP→0
H4 HĐ user MyContractsPage.tsx:225 CÓ (thị giác) như H3
H5 Bản cứng admin HardCopiesPage.tsx:117 CÓ (thị giác) như H3
H6 Bản cứng user HardCopiesPage.tsx:117 CÓ (thị giác) như H3
H7/H8 trang Duyệt NCC …ListPage.tsx:524 CÓ (thị giác) GĐ1 dùng stage1Content (không ảnh hưởng), GĐ2-4 rỗng: SẮP→0
0 host đổi API/prop/luồng — patch nằm trọn trong component trình-bày, prop-surface KHÔNG đổi ⇒ 0 rủi ro hồi-quy hành vi. Chỉ pixel đổi.

E. (O6) Truy điều-hướng — ĐÚNG đường đang đi

useNavigate chỉ khai ở usePipelineStages.ts:200. PipelineStageFolders.tsx = 0 hit chuỗi navigate (kể cả comment); PipelineTreePanel.tsx = 1 hit DUY NHẤT và là COMMENT (:97 "…8 ngăn nhóm + navigate…"), 0 lời gọi. Toàn bộ 6 lời gọi thật nằm ở hook:

# file:line Leaf/nút Đích hiện tại Callback-hoá?
N1 usePipelineStages.ts:219 CARD PHIẾU PE (GĐ1) navigate(\/purchase-evaluations?id=${p.id}&expand=1`)` CHƯA — ĐÚNG lỗi O6 owner báo
N2 :233 Card/leaf KHKK (GĐ2) opts.onOpenKhkk ? onOpenKhkk(k.id) : navigate(\/khkk/${k.id}`)` ĐÃ (M-2 S171) — khuôn mẫu để nhân bản
N3 :263 Tên ngăn nhóm N1-N8 navigate(\/khkk/list?group=${n}`)` (rời trang CÓ CHỦ ĐÍCH: "mở danh sách đã lọc")
N4 :272 Tên ngăn "(chưa phân nhóm)" navigate('/khkk/list') (như N3)
N5 :366 Leaf HĐ (GĐ3) navigate(\/contracts/${contractId}`)`
N6 :378 Leaf bản cứng (GĐ4) navigate(\/hard-copies?type=${c.type}`)`

Chuỗi sự-kiện lỗi owner gặp (H1/H2, màn KHKK): KhkkListPanel KHÔNG truyền stage1ContentusePipelineStages.ts:204-206 rơi xuống nhánh else :207-222 ⇒ mỗi phiếu PE thành leaf onOpen = N1 ⇒ PipelineStageFolders.tsx:119 onClick={leaf.onOpen}rời màn KHKK sang /purchase-evaluations?id=…&expand=1. Khớp nguyên văn owner.

⚠️ Đối xứng bị bỏ quên — owner nói "tương tự với duyệt NCC": ở H7/H8 (trang Duyệt NCC) GĐ1 dùng stage1Content nên PE ở-tại-chỗ ✔, nhưng leaf GĐ2 chạy N2 nhánh-mặc-định ⇒ navigate('/khkk/:id') ⇒ VĂNG khỏi màn Duyệt NCC. Lỗi TỒN TẠI ĐỐI XỨNG 2 chiều: KHKK→PE (N1) và PE→KHKK (N2 khi host không truyền callback).

F. (O6) Đề xuất callback-hoá — mirror ĐÚNG khuôn onOpenKhkk (M-2)

F1. Patch tối thiểu, 3 file + 2 host

(a) usePipelineStages.ts — thêm 1 option, sửa 1 dòng:

// trong BuildStagesOptions (cạnh onOpenKhkk :185)
/** [O6] Bấm leaf phiếu PE (GĐ1) ⇒ HOST xử lý tại chỗ thay vì rời trang.
 *  KHÔNG truyền ⇒ giữ NGUYÊN navigate — 0 hồi-quy cho mọi call-site cũ. */
onOpenPe?: (id: string) => void
// :219 — ĐỔI ĐÚNG 1 dòng, sao y khuôn N2 (:233)
onOpen: opts.onOpenPe ? () => opts.onOpenPe!(p.id) : () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`),

(b) PipelineStageFolders.tsx — thêm trạng-thái ĐANG CHỌN cho leaf (bắt buộc, xem F3):

// PipelineLeaf (:46-56)
selected?: boolean
// LeafList (:117-125): thêm aria-current + nền active
aria-current={leaf.selected ? 'true' : undefined}
className={cn('block w-full px-3 py-2 …', leaf.selected && 'bg-brand-50 ring-1 ring-inset ring-brand-200', )}

(c) PipelineTreePanel.tsx — prop mới, KHÔNG nhét vào stage2Options:

// cạnh :98
/** [O6] Tuỳ chọn GĐ1. Tách khỏi `stage2Options` để tên prop không nói dối. */
stage1Options?: Pick<BuildStagesOptions, 'onOpenPe'> & { selectedPeId?: string | null }
// :334
stages={buildStages(wg.items, { ...stage2Options, ...stage1Options })}

🔴 KHÔNG nhồi onOpenPe vào stage2Options (dễ nhưng sai tên ⇒ người sau đọc stage2Options.onOpenPe sẽ tưởng là callback GĐ2).

(d) Host H1/H2 KhkkListPanel.tsx:292-297:

<PipelineTreePanel
  currentStage={2}
  hideHeader
  bodyMaxHClass="max-h-[28rem] lg:max-h-none"
  stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
  stage1Options={{ onOpenPe: onSelectPe, selectedPeId }}   // ← MỚI
/>

onSelectPe + selectedPeId là prop mới của KhkkListPanel, do KhkkListPage cấp.

(e) KhkkListPage.tsx — nơi phiếu PE hiện TẠI CHỖ (chỉ-xem):

  • state qua URL cho deep-link: ?pe=<id> (song song ?id= của KHKK). Chọn PE ⇒ clear id, chọn KHKK ⇒ clear pe (panel 2 chỉ một nội dung).
  • <lg PHẢI giữ nhánh rời trang, y hệt selectPlan (:102-109), nếu không mobile bấm PE = không có gì xảy ra:
function selectPe(id: string) {
  if (window.matchMedia('(min-width: 1024px)').matches) { setPe(id); setId(null) }
  else navigate(`/purchase-evaluations?id=${id}&expand=1`)
}
  • Panel 2 (:162-179) thêm nhánh — tái dùng component sẵn có, KHÔNG dựng viewer mới:
{selectedPeId && peDetail.data && (
  <PeDetailTabs evaluation={peDetail.data} onBack={() => setPe(null)} onDelete={() => {}} readOnly />
)}

Đã VERIFY chữ ký: PeDetailTabs.tsx:103-119evaluation: PeDetailBundle · onBack: () => void · onDelete: () => void (cả 2 BẮT BUỘC, không ?) · readOnly?: boolean = false. Nút Xoá bị chặn bởi :257 (… && !readOnly) ⇒ với readOnly thì onDelete không có đường chạm() => {} an toàn — ghi comment ngay tại chỗ kẻo người sau tưởng quên nối. Tiền lệ: trang PE cũng truyền readOnly={true}PurchaseEvaluationsListPage.tsx:657-663.

  • query: useQuery({ queryKey: ['khkk-pe-detail', selectedPeId], queryFn: … GET /purchase-evaluations/${id}, enabled: !!selectedPeId })key RIÊNG, không mượn key của trang PE (bài S159: đổi/đụng cache dùng chung làm vỡ trang khác).
  • Panel 3 (:186-202) khi đang xem PE: KHÔNG render PeWorkflowPanel (nó mang nút duyệt/xoá phiếu PE — cấp thao tác duyệt PE từ màn KHKK là sai lane quyền). Thay bằng hộp dashed + 1 lối thoát tường minh: nút "Mở phiếu Duyệt NCC gốc ↗"navigate('/purchase-evaluations?id=…&expand=1'). Owner nói "vẫn cho hiển thị ra", không cấm mở trang gốc — chỉ cấm tự động văng.

F2. 🔴 "tương tự với duyệt NCC" — 2 cách hiểu, cả 2 đều dẫn tới cùng một máy

  • (i) Trên màn Duyệt NCC cũng phải ở-tại-chỗ: hiện leaf GĐ2 trên cây trang PE chạy N2 nhánh mặc định ⇒ navigate('/khkk/:id') ⇒ VĂNG khỏi màn Duyệt NCClỗi đối xứng, ĐANG TỒN TẠI.
  • (ii) Làm y như khuôn PE đang làm: chính là stage1Content + onOpenKhkk. → Đề nghị ship 2 nấc: N1 = màn KHKK (F1, nhỏ, đúng nguyên văn owner); N2 = màn Duyệt NCC truyền onOpenKhkk → panel 2 render <KhkkDetailContent plan readOnly/> (component đã nhận readOnly, chứng cứ KhkkListPage.tsx:177) + query ['pe-khkk-detail', id]. Nấc 2 to hơn (thêm 1 query + 1 nhánh panel ở trang PE) ⇒ khai với owner là 2 lượt, đừng gộp im lặng rồi vỡ trang PE đang golive.

F3. Vì sao BẮT BUỘC có selected (không phải trang trí)

KhkkPlanCardselected={selectedId === k.id} (KhkkListPanel.tsx:241) nên bấm card KHKK là cây sáng lên. Leaf PE không có khái niệm đó (PipelineLeaf :46-56). Nếu chỉ chuyển navigate → callback mà không thêm selected: người dùng bấm phiếu PE, panel 2 đổi ở cột giữa trong khi cột trái im như không nhận lệnh ⇒ họ bấm lại lần 2, lần 3. Đây là hồi-quy trải-nghiệm do chính bản vá gây ra — FD4 "Polish/states" fail. selected + aria-current là phần không thể cắt.

F4. Ảnh hưởng lên 6 host — O6

Host Đổi hành vi? Cụ thể
H1 KHKK admin KhkkListPanel.tsx:292 🔴 CÓ — đây là mục tiêu bấm card PE: navigate('/purchase-evaluations?id=…&expand=1')ở lại màn KHKK, panel 2 = PeDetailTabs readOnly, leaf sáng selected. <lg vẫn rời trang (không có panel 2)
H2 KHKK user 🔴 mirror H1
H3 HĐ admin ContractsListPage.tsx:249 KHÔNG không truyền stage1Optionsopts.onOpenPe === undefined ⇒ nhánh navigate cũ nguyên vẹn
H4 HĐ user MyContractsPage.tsx:225 KHÔNG như H3
H5 Bản cứng admin KHÔNG như H3
H6 Bản cứng user KHÔNG như H3
H7/H8 trang Duyệt NCC :524 KHÔNG ở nấc 1 GĐ1 vẫn dùng stage1Content (onOpenPe không đụng tới nhánh content). Nấc 2 mới đổi (leaf GĐ2)
Cơ chế bảo đảm "không đổi": cùng khuôn ternary đã chứng minh ở M-2 (:233) — vắng callback ⇒ rơi về navigate. Đây là lý do chọn callback tuỳ chọn thay vì đổi thẳng đích navigate.

G. Style/UX delta cây: GĐ2 (KHKK) vs GĐ1 (Duyệt NCC = CHUẨN)

# Delta Bằng chứng Verdict
G1 KHKK truyền hideHeader ⇒ mất thanh "Cây toàn trình · N gói" và mất nút "Hiện/Ẩn cây"; :233-234 phải ép flex để <lg còn thấy cây PipelineTreePanel.tsx:104-109, :234 ⚠️ MINOR THẬT: trên mobile màn KHKK, cây luôn mở, không thu gọn được, đẩy danh sách phiếu xuống dưới màn hình. Trang PE có đường thu gọn. Đề xuất: khi hideHeader, <lg mặc định đóng + 1 nút text nhỏ "Hiện cây" phía trên thân
G2 KHKK bỏ vỏ card-accent + lg:sticky :203 ĐÚNG — owner S174 chốt đồng bộ viền; cây nhúng trong panel không cần vỏ thứ hai
G3 GĐ2 trên màn KHKK không có 8 ngăn N1-N8 (vì dùng content), còn trên màn PE/HĐ/Bản-cứng thì CÓ usePipelineStages.ts:168-171, :292-297 🔴 LÝ-LẼ ĐÃ MẤT CHÂN ĐẾ: comment :170 biện minh "lối vào nhóm giữ ở Select nhóm của khối filter panel 1"nhưng Select nhóm ĐÃ BỊ GỠ (KhkkListPanel.tsx:184-186: "Select nhóm đã gỡ khỏi UI. Nhóm nay đến DUY NHẤT từ URL"; khối filter :257-288 chỉ còn ô tìm + Select trạng thái). ⇒ sửa comment cho khỏi nói dối, và cân nhắc trả 8 ngăn về khi group === null
G4 Leaf GĐ1 (mã mono + badge, compact) vs card GĐ2 (KhkkPlanCard, giàu) usePipelineStages.ts:209-221 vs KhkkListPanel.tsx:239-244 NHẤT QUÁN có nguyên tắc: GĐ đang đứng = card, GĐ khác = leaf. GIỮ
G5 Emoji thay icon ở 3 tầng trên: 📅 :294 · 📁 :310 · 🧱 :326 — trong khi tầng 4 (folder GĐ) dùng lucide (STAGE_ICON PipelineStageFolders.tsx:29-34) PipelineTreePanel.tsx:294/310/326 ANTI-SLOP CATCH (FD3): 2 bộ icon lệch nhau trong cùng một cây. Emoji còn đổi hình theo OS và không nhận màu token. Đề xuất lucide CalendarDays / FolderClosed / Layers + icon-chip như folder GĐ
G6 Chevron 3 tầng trên = <svg> inline thủ công ×3 (lặp), tầng 4 = <ChevronRight> lucide :293/309/325 vs PipelineStageFolders.tsx:165/245 cùng lớp G5 — thay bằng lucide, bớt 3 khối SVG lặp
G7 Chevron 3 tầng trên thiếu motion-reduce:transition-none (folder GĐ có) :293/309/325 vs PipelineStageFolders.tsx:167/247 FD5: prefers-reduced-motion chỉ được tôn trọng nửa cây
G8 <summary> 3 tầng trên thiếu hoàn toàn focus-visible:ring… (folder GĐ + ngăn nhóm đều có) :292/308/324 vs PipelineStageFolders.tsx:164/244 FD5 FAIL THẬT: đi bàn phím qua Năm/Dự án/Hạng mục mất dấu focus, tới folder GĐ mới thấy lại. Vá: dán đúng chuỗi focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60
G9 Chip đếm 3 tầng trên: bg-slate-200 text-slate-700 (:296) và bg-slate-100 text-slate-600 (:312/328) — tầng 4 lại style.count theo màu GĐ chấp nhận: xám = tầng cấu-trúc, màu = tầng nghiệp-vụ. Nhưng nên thêm tabular-nums cho cả 3 (cùng lý do D1)

H. Spec thi-hành-được cho Opus 5 MAX (thứ tự áp)

S0 — tiền đề: 3 file MIRROR ×2 app. Mọi bước dưới áp 2 lần, chốt bằng sha256sum 3 cặp (kỳ vọng: 3 cặp khớp). Không có bước nào chạm BE/DB.

Bước Việc File Rủi ro
P1 O2: thay SoonChipCountChip/UnknownChip (D1) + vá 6 comment (D4) PipelineStageFolders.tsx (+usePipelineStages.ts comment) Thấp — 0 đổi prop-surface
P2 O2-hỏi: chốt ca locked = "—" hay "0" (D2) Cần 1 câu owner
P3 O6: onOpenPe + 1 dòng :219 (F1a) usePipelineStages.ts Thấp — ternary vắng-callback giữ nguyên
P4 O6: selected + aria-current + nền active (F1b) PipelineStageFolders.tsx Thấp
P5 O6: prop stage1Options + spread (F1c) PipelineTreePanel.tsx Thấp — optional
P6 O6: ?pe= + panel 2 PeDetailTabs readOnly + panel 3 hộp dashed + nhánh <lg (F1d/e) KhkkListPage.tsx · KhkkListPanel.tsx Trung bình — đây là chỗ dễ vỡ nhất (loại trừ idpe, <lg)
P7 (tuỳ chọn, khuyến nghị) FD5/FD3: G8 focus-ring → G7 motion-reduce → G5/G6 lucide PipelineTreePanel.tsx Thấp, thuần class
P8 Escalate D6 (PePipelineStrip) + G3 (comment Select nhóm đã gỡ) ngoài lens

Nghiệm thu (đo được, không cảm tính):

  1. Có control-dương, KHÔNG dùng phép trích rỗng. ⚠️ ĐỪNG grep ">Sắp<": JSX để chữ trên DÒNG RIÊNG (:103-105) nên mẫu đó trả 0 hit ngay cả lúc chưa vá — 0-hit giả đọc y hệt đã-sạch.
    • Control-dương ĐO TRƯỚC PATCH (2026-08-05): grep -rn "SoonChip" fe-admin/src fe-user/src8 hit (mỗi app 4: định nghĩa :99 + gọi :195/:233/:273).
    • Sau patch: 0 hit. Chênh 8→0 mới là bằng chứng.
    • grep -rn "Sắp" fe-admin/src fe-user/src sau patch ⇒ chỉ còn DashboardPage.tsx:113 ("Sắp quá hạn (24h)" — nghĩa khác, KHÔNG đụng) và PePipelineStrip.tsx nếu P8 chưa làm. Phải liệt kê thẳng phần còn lại khi báo cáo, đừng khai "sạch".
  2. npm run build PASS 0 TS error ×2 app.
  3. sha256sum 3 cặp file mirror ⇒ khớp 3/3.
  4. grep -c "navigate(\|navigate('" fe-admin/src/hooks/usePipelineStages.ts⇒ **vẫn 7 DÒNG** (đo trước-patch 2026-08-05: 7 = **6 lời gọi** :219/:233/:263/:272/:366/:378 **+ 1 comment** :182 — đừng đọc 7 thành "7 lời gọi"). Callback là nhánh THÊM, KHÔNG xoá nhánhnavigate` nào ⇒ đây là bằng chứng máy-đo cho "5 host kia không đổi hành vi".
  5. Soi mắt: KHKK screen — bấm card PE không đổi URL path (chỉ thêm ?pe=), panel 2 hiện phiếu PE chỉ-xem, leaf trái sáng.

I. Rủi-ro / KHÔNG được đụng

  1. KHÔNG đổi PIPELINE_STAGE_LABELS (:22-27) — nhãn VERBATIM lời owner, :6-8 khai rõ GĐ2 CỐ Ý khác nhãn strip.
  2. KHÔNG đổi quyết định "hiện đủ 8 ngăn kể cả rỗng" — chỉ đổi chữ trên chip + lý-lẽ trong comment.
  3. KHÔNG đổi gate quyền (KHKK_MENU_KEYS = ['KeHoachKyKet'] :57) — F-1/S162, gotcha #85.
  4. KHÔNG sửa PePipelineStrip trong lane này (ngoài 3-file lens) — escalate.
  5. KHÔNG xoá nhánh navigate cũ khi thêm callback — 5-6 host sống nhờ nó.
  6. stage2Content luôn đi kèm stage2Count (PipelineStageFolders.tsx:95-97); nếu ai đó thêm stage1Content cho KHKK thì luật y hệt.
  7. PeDetailTabs mặc định readOnly=falsequên readOnly là mở nút thao tác phiếu PE ngay trong màn KHKK. Bắt buộc truyền.
  8. Cửa sổ index 200 (INDEX_PAGE_SIZE :47, PE_INDEX_PAGE_SIZE PipelineTreePanel.tsx:22) không đổi ⇒ "0" trên folder vẫn có thể là "0 trong 200 bản ghi mới nhất"; dòng khai truncated (PipelineStageFolders.tsx:301-305) giữ nguyên, đừng bỏ — nó chính là thứ giữ cho số 0 không nói dối.

J. FD2 visual-loop — KHAI THẲNG

Lượt này KHÔNG có screenshotkhông bịa: (a) lane là audit/spec, 0 dòng code UI đổi ⇒ không có gì mới để chụp; (b) màn KHKK nằm sau ProtectedRoute + cần API+SQL, dev-rig authed đã ghi nhận chặn (MEMORY gotcha #3: vite proxy http://localhost:5443 ⟂ API bind HTTPS). Thay thế: static-analysis + số đo tương phản tính tay theo WCAG 2.1 (D3) + đối chiếu class token trực tiếp trên đĩa. Khi P1-P7 land, FD2 loop chạy trên prod sau deploy (đường tin cậy nhất theo MEMORY).

K. Bổ sung sau khi đo — file HOST cũng đang MIRROR

sha256sum (2026-08-05): components/khkk/KhkkListPanel.tsx ad9073db…c314d khớp 2 app · pages/khkk/KhkkListPage.tsx ee0dc8af…b69f4 khớp 2 app. ⇒ Bước P6 (chỗ sửa nhiều nhất của O6) cũng phải áp ×2 app và giữ SHA khớp. Tổng cộng O2+O6 chạm 5 cặp file = 10 file, nghiệm thu 5/5 cặp SHA khớp. Bỏ sót 1 bên ⇒ admin và user lệch hành vi im lặng (đúng lớp lỗi mà 3 header file cảnh báo).


Bảng phủ owner-item

Item Đòi hỏi Phủ ở Trạng thái
O2 chữ SẮP sinh ở đâu (file:line) §C bảng S-a/S-b/S-c — :99-107 def, gọi :195/:233/:273
O2 điều kiện bật từng ca §C cột 3
O2 GĐ nào còn đúng "sắp" / GĐ nào đã live §C — không GĐ nào còn đúng, cả 4 đều fetch API thật
O2 đổi sang 0 thì đụng gì §D1 (patch) + §D4 (6 comment) + §D3 (contrast)
O2 có host nào dựa vào chữ SẮP không §D5 — KHÔNG (SoonChip không export, 8-hit nội bộ)
O6 truy onOpenKhkk + mọi navigate( trong 3 file §E bảng N1-N6 + §số-đo-nền
O6 chỉ ra ĐÚNG đường đang điều hướng §E — usePipelineStages.ts:219/purchase-evaluations?id=…&expand=1
O6 đề xuất callback-hoá mirror stage2Options.onOpenKhkk §F1 (a-e) — onOpenPe, cùng khuôn ternary :233
BẮT BUỘC 6 host bị ảnh hưởng thế nào — cho CẢ HAI đề xuất §D7 (O2, 6 host + H7/H8) · §F4 (O6, 6 host + H7/H8)

END sub-frontend-designer-L4 — VERDICT=SHIP-SPEC-READY (2 đề xuất thi-hành-được; 1 câu hỏi owner treo ở §D2; 1 escalate ngoài lens ở §D6)