Files
solution-erp/.claude/workflows/runs/2026-07-29-S159-tong-quan-pipeline-menu/sub-reviewer-diff-dot4.md
2026-07-29 13:52:45 +07:00

28 KiB
Raw Blame History

sub-reviewer-diff-dot4 — soi đối kháng diff đợt-4 "Danh sách phiếu toàn trình" (S159 L8)

Run: 2026-07-29-S159-tong-quan-pipeline-menu · vai: reviewer (adversarial, READ-only) Vật soi: git diff -- fe-user/src/pages/UserDashboardPage.tsx — 1321 dòng (984+/337), 1 file. Tác giả diff: frontend-designer (sub-file sub-frontend-designer-dot4.md). Vai đó CHƯA tự-review ⇒ đây là lớp review DUY NHẤT của đợt-4. Tiền lệ: đợt-1 đã soi cùng trang (sub-reviewer-diff-tongquan.md, 7 FLAG 0H/2M/5L) — mục §A của đợt-1 CÒN GIÁ TRỊ, không lặp lại; đợt này thêm trục REGRESSION so với đợt-1. 🔴 Ghi đĩa TỪNG FLAG ngay khi tìm ra (chống #53). KHÔNG batch. 🔴 Giới hạn khai trước: KHÔNG live-verify (chưa deploy, /dashboard cần login) ⇒ toàn bộ là soi TĨNH trên diff + đọc mã nguồn BE/FE/route. Mọi phát biểu về runtime là suy luận từ mã, đánh dấu rõ chỗ chưa chứng được.


0 — Tiến trình (append-only, ghi trong lúc làm)

  • [t0] Đã chạm đĩa: git diff --stat → đúng 1 file fe-user/src/pages/UserDashboardPage.tsx 984+/337. Các file M khác (docs/STATUS.md, docs/HANDOFF.md, scripts/*, .claude/commands/*, .claude/governance/*) KHÔNG thuộc vật soi (của lead). 0 file fe-admin/**. ràng buộc "fe-user ONLY" THOẢ.

  • [t1] Trục 1 (BỎ) đo xong · Trục 7 (route) đo xong · FLAG-1 chốt (xem dưới).


FLAG — ghi từng cái, ngay khi chốt

FLAG-1 [M] — bấm dòng hợp đồng trên ĐIỆN THOẠI dẫn tới ngõ cụt (mở ra danh sách, không mở ra hợp đồng)

fe-user/src/pages/UserDashboardPage.tsx:1395onOpen={id => navigate(/my-contracts?id=${id})}.

Trang đích fe-user/src/pages/contracts/MyContractsPage.tsx chỉ render nội dung hợp đồng ở Panel 2/3, mà cả hai panel đều hidden … lg:block (MyContractsPage.tsx:172:189). Dưới 1024px người dùng chỉ thấy Panel 1 là danh sách — hợp đồng vừa bấm KHÔNG mở ra. Chính trang đó đã tự biết điều này: selectContract() (MyContractsPage.tsx:59-68) có nhánh window.matchMedia('(min-width: 1024px)'), hễ hẹp hơn thì navigate('/contracts/:id') fullpage. Bảng mới trên trang chủ không có nhánh đó, nên nó phá đúng quy ước mà trang đích đặt ra.

Mức phơi nhiễm tăng thật chứ không chỉ trên lý thuyết: bảng GĐ3 giữ 3 cột (Mã HĐ · Tên hợp đồng · Trạng thái) ở mọi bề rộng (:860-866, các cột còn lại mới hidden md/lg/xl), tức dòng bấm được trên điện thoại; và nó thay cho danh sách "HĐ gần đây" 5 dòng cũ bằng danh sách phân trang đầy đủ.

Khai trung thực về mức lỗi: đây là tật kế thừa, KHÔNG phải hồi quy — bản cũ cũng navigate(/my-contracts?id=${c.id}) (dòng bị xoá trong diff). Tôi vẫn để mức M vì đợt-4 nhân diện tích chạm của nó lên nhiều lần và biến nó thành đường mở hợp đồng CHÍNH của trang chủ.

Fix đề nghị: dùng lại đúng nhánh của trang đích — nếu window.matchMedia('(min-width: 1024px)').matches thì /my-contracts?id=<id>, ngược lại /contracts/<id> (route có thật, App.tsx:60). Hoặc đơn giản hơn: luôn /contracts/<id>.

FLAG-2 [M] — cột "Hành trình" nói CHẮC một điều nó không biết: "các giai đoạn còn lại chưa tới"

fe-user/src/pages/UserDashboardPage.tsx:337-344 khai DOT_TEXT.none = 'chưa tới', rồi :378 ghép thành nhãn đọc-được của cả cụm: "Hành trình toàn trình: giai đoạn N … ; các giai đoạn còn lại chưa tới.", và :389 gắn tooltip từng chấm "Giai đoạn 1 · Duyệt NCC — chưa tới".

Hai chỗ câu đó SAI:

  1. Dòng hợp đồng (bảng GĐ3). Một hợp đồng đang ở giai đoạn 3 thì giai đoạn 1 và 2 là các giai đoạn phía trước nó, không thể "chưa tới". Người dùng rê chuột lên chấm đầu sẽ đọc "Giai đoạn 1 · Duyệt NCC — chưa tới" cho một hợp đồng đã đi qua chặng đó (hoặc ít nhất không ai biết là chưa).
  2. Dòng phiếu đã sinh hợp đồng (bảng GĐ1). PeListItem.contractId (fe-user/src/types/purchaseEvaluation.ts:126) có thật trong payload đang tải — backend chiếu thẳng x.e.ContractId vào DTO (src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs, dòng projection x.e.SelectedSupplierId, …, x.e.ContractId, …). Phiếu nào contractId != null là phiếu đã sinh hợp đồng, tức GĐ3 đã tới; vậy mà chấm GĐ3 vẫn báo "chưa tới". Không tốn thêm một request nào để biết điều này.

Tác giả có ghi lý do trong mã (:331-332): không suy diễn "có HĐ ⇒ GĐ1/GĐ2 xong" vì chưa có cầu nối per-phiếu. Lập luận đó ĐÚNG cho chiều "đã xong", nhưng câu chữ được chọn lại khẳng định chiều ngược lại — "chưa tới" cũng là một khẳng định, và là khẳng định sai. Không biết thì phải nói là không biết.

Đây đúng lớp lỗi đợt-1 đã ghi (FLAG-3 "nói quá điều dữ liệu chứng minh được"), tái phát ở chỗ mới.

Fix đề nghị: (a) đổi DOT_TEXT.none thành câu trung tính, ví dụ "chưa có thông tin ở giai đoạn này", và bỏ mệnh đề "các giai đoạn còn lại chưa tới" khỏi aria-label; (b) nếu muốn đúng hơn nữa mà vẫn 0 request: phiếu có contractId != null thì cho chấm GĐ3 mang trạng thái riêng (ví dụ "đã sinh hợp đồng").

FLAG-2 · bổ sung (ca thứ ba, cùng một lớp lỗi): hợp đồng ở phase DangDongDau(8) hoặc DaPhatHanh(9) — tức đã đóng dấu / đã phát hành, đúng phần việc mà chính trang này gọi là giai đoạn 4 "Hợp đồng cứng" (bước 19 → 21) — vẫn được contractDot (:410-416) chấm vào giai đoạn 3, còn chấm giai đoạn 4 báo "chưa tới". Bản hợp đồng cứng đã phát hành rồi mà hành trình bảo giai đoạn hợp đồng cứng chưa tới.

FLAG-3 [L] — nút phân trang tính từ DỮ LIỆU TRẢ VỀ chứ không từ trang đang chọn ⇒ bấm nhanh 2 lần chỉ đi được 1 trang

fe-user/src/pages/UserDashboardPage.tsx:611:618onClick={() => onPage(data.page - 1)} / onPage(data.page + 1), trong đó data là dữ liệu đã tải xong, không phải state page. Hai bảng đều bật placeholderData: keepPreviousData (:1151, :1163), nghĩa là trong lúc trang mới đang bay thì data vẫn là trang cũ.

Hệ quả đo được từ mã: đang ở trang 1, bấm "Sau" → setPage(2), request bay, data.page vẫn = 1. Bấm "Sau" lần nữa trước khi request về → tính ra 1 + 1 = 2setPage(2) trên state đã là 2 ⇒ không có gì xảy ra. Người dùng bấm hai lần nhưng chỉ tiến một trang, và nút "Sau"/"Trước" trong khoảng đó bật/tắt theo hasNext/hasPrev của trang cũ.

Kèm theo, cùng chỗ này có một ca hiển thị vô nghĩa: nếu tổng số bản ghi giảm trong lúc đang xem trang cuối (người khác xoá phiếu, rồi query refetch), from = (page-1)*pageSize+1 có thể lớn hơn data.total, dòng :607-608 in ra dạng "Hiển thị 4130 trong 30 phiếu" và bảng rơi vào empty-state "Chưa có phiếu Duyệt NCC nào" trong khi thực tế vẫn còn 30 phiếu.

Fix đề nghị: tính từ state — truyền page xuống Pager và dùng onPage(page + 1) / onPage(page - 1); thêm chốt if (data.page > data.totalPages && data.totalPages > 0) setPage(1) (hoặc kẹp from = Math.min(from, data.total)).


TRỤC 2 — param BE có thật không, và tab không chọn có bắn request không

Đã đọc thẳng controller, không tin lời khai:

Điều được khai Đo ở đâu Kết quả
/purchase-evaluations nhận page·pageSize·search·sortDesc·type·phase·projectId·approvalWorkflowId src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs:18-28 ĐÚNG cả 8
/contracts nhận page·pageSize·search·sortDesc·phase·supplierId·projectId ContractsController.cs:16-24 ĐÚNG cả 7
search PE lọc MaPhieu TenGoiThau Project.Name Project.Code PurchaseEvaluationFeatures.cs (khối if (!string.IsNullOrWhiteSpace(request.Search))) ĐÚNG — placeholder :1352 "Tìm mã phiếu, gói thầu, dự án…" mô tả đúng chừng đó, không hứa quá
search HĐ lọc MaHopDong TenHopDong Supplier.Name Project.Name ContractFeatures.cs:309-316 ĐÚNG — placeholder :1380 khớp
pendingMe KHÔNG phải param BE PurchaseEvaluationsController.cs:30-35/inbox là endpoint riêng, trả List<> phẳng ĐÚNG, và mã ghi rõ điều này ở đầu file (:33-35)
hasNext/hasPrev/totalPages do BE tính src/Backend/SolutionErp.Application/Common/Models/PagedResult.cs:9-11 CÓ THẬT (HasNext => Page*PageSize < Total, HasPrev => Page > 1) ⇒ nút biên bind thẳng là đúng

Lazy per-tab: enabled: stage === 1 (:1150) và enabled: stage === 3 (:1162). Đứng ở tab GĐ2 hoặc GĐ4 ⇒ 0 request danh sách. Đứng ở GĐ1 ⇒ chỉ dash-stage-pe bắn, dash-stage-contracts im, và ngược lại. đúng như khai.

Tổng request lúc mở trang (tab GĐ1): 5 query nền (my-dashboard, pipeline-pe-total, pipeline-contract-total, pipeline-pe-approved, pe-inbox) + 1 query danh sách = 6. Bản HEAD là 5. Tức +1 request, nhưng 2 query *-recentpageSize=5 đã rút xuống pageSize=1 nên tải về NHẸ hơn. Không phải bão request: main.tsx:7-14 đặt refetchOnWindowFocus:false + retry:1.

TRỤC 5 — hồi quy so với vòng-1 (đo HEAD ⟂ cây làm việc, không đo bằng trí nhớ)

Nền so sánh: HEAD = 766 dòng và ĐÃ chứa hero + các bản vá vòng-1 (đợt 1-3 đã commit) ⇒ diff 984+/337 này đúng là đợt-4 đứng một mình.

Thứ phải còn sống Phép đo HEAD Cây làm việc Kết luận
Bản vá FLAG-1 vòng-1 (aria-label mang tên giai đoạn) đếm group=aria-label={`${group} 6 / 1 6 / 1 CÒN NGUYÊN
Nhãn 4 GĐ chốt so từng chuỗi với components/pe/PePipelineStrip.tsx:15-19 (nguồn chuẩn) Duyệt NCC · Kế hoạch ký kết HĐ · Duyệt hợp đồng · Hợp đồng cứng KHỚP TỪNG CHỮ; nay còn gom về một mảng STAGES (:131-148) nên hero và tab không thể lệch nhau
['pe-inbox'] dùng chung + select đếm so key ⟂ hàm tải với pages/InboxPage.tsx:76-79 1 1 CÒN NGUYÊN — cùng key, cùng hàm tải trả MẢNG, chỉ select: d => d.length ở phía quan sát (:1070-1074) nên không phá cache của Hộp thư
Bản vá FLAG-2 vòng-1 (chữ "Pipeline" / "Upload") đếm "Toàn trình 4 giai đoạn" và "Tải lên bản hợp đồng" 1 / 1 1 / 1 CÒN NGUYÊN
Tiếng Việt 100% ở phần MỚI rút mọi chuỗi hiển thị mới rồi soi từng chuỗi KHÔNG lọt chữ Anh nào. (Sắp, Sắp triển khai, Trước, Sau, Trang, Hiển thị, Xoá ô tìm kiếm, Mở màn Duyệt NCC, Hành trình, (chưa cấp mã), (chưa đặt tên)… đều thuần Việt)
0 dependency mới git diff --stat -- fe-user/package.json fe-admin/package.json rỗng 0 thay đổi
Named export grep "export function UserDashboardPage" 1 1 CÒN NGUYÊN
Không đụng fe-admin git diff --name-only | grep fe-admin 0 file THOẢ
Đích điều hướng của hero liệt kê mọi navigate(...) ở HEAD ⟂ cây làm việc 8 dạng 10 dạng 8 dạng cũ giữ y nguyên, 2 dạng mới (/purchase-evaluations/new, /coming-soon?stage=N) đều là route CÓ THẬT (App.tsx:52,65)

Ghi chú về một con số thoạt nhìn giống hồi quy: chuỗi "Duyệt hợp đồng" giảm 5 → 2. Truy ra: HEAD có 1 lần ở chú thích + 1 lần title= + 3 lần group= (truyền tay cho từng dòng số liệu); nay còn 1 chú thích + 1 lần khai trong STAGES, còn group đọc gián tiếp qua STAGES[2].title. Không mất nhãn nào.

FLAG-4 [L] — người dùng trình đọc màn hình không được báo khi kết quả tìm kiếm đổi

fe-user/src/pages/UserDashboardPage.tsx:606 đặt aria-live="polite" lên dòng "Hiển thị xy trong N", nhưng chính khối chứa nó bị gỡ khỏi cây khi không có kết quả (:601if (!data || data.total === 0) return null). Nghĩa là đúng lúc cần thông báo nhất — gõ một từ khoá không khớp gì — vùng thông báo biến mất, và khối trạng thái rỗng thay thế nó (:704-731) thì không nằm trong bất kỳ vùng aria-live nào. Người dùng bàn phím/trình đọc gõ xong sẽ không nghe gì cả và không biết bảng vừa đổi.

Fix đề nghị: giữ vùng aria-live="polite" luôn tồn tại (khi rỗng thì thông báo "Không tìm thấy phiếu nào khớp"), hoặc gắn role="status" cho khối trạng thái rỗng.

FLAG-5 [L] — tính năng chủ đạo của đợt-4 biến mất trên điện thoại

Cột "Hành trình" — thứ mà chính đầu bài gọi là tầng liên kết thứ hai, và là câu trả lời cho yêu cầu "nhìn vào vẫn thấy được tổng quan liên kết" — được đặt hidden … md:table-cell ở cả hai bảng (fe-user/src/pages/UserDashboardPage.tsx:672:866, kèm ô dữ liệu :812:977). Dưới 768px cột này không tồn tại, nên trên điện thoại người dùng chỉ còn dải hero ở trên, không còn thông tin "từng phiếu đang đứng đâu".

Chọn ẩn cột trên màn hẹp là quyết định hợp lý về mặt bố cục — tôi không đòi hiện nguyên bảng 7 cột trên điện thoại. Điều đáng ghi là phần bị hy sinh lại đúng là phần được giao làm, và dòng phụ gộp cho mobile (:765-767) chỉ gộp Dự án + Ngày, không gộp hành trình.

Fix đề nghị: đưa cụm 4 chấm vào dòng phụ trên mobile (cùng chỗ với Dự án · Ngày), hoặc đổi ngưỡng ẩn của cột Người soạn/Ngày để nhường chỗ cho Hành trình.

FLAG-6 [L] — hai con số đếm cùng một thứ, hiện cạnh nhau, không khớp nhau khi đang tìm kiếm

Số trên thẻ tab lấy từ peTotalQuery / contractTotalQuery (:1333) — đây là tổng không kèm từ khoá. Bảng ngay bên dưới lại lọc theo search (:1147, :1159), và dòng phân trang in tổng có kèm từ khoá (:608). Gõ một từ khoá xong, cùng một màn hình sẽ có thẻ tab ghi "Giai đoạn 1 · 57" trong khi dòng dưới ghi "Hiển thị 13 trong 3 phiếu".

Không sai dữ liệu — hai số đo hai thứ khác nhau — nhưng đặt cạnh nhau mà không chú thích thì người đọc phải tự đoán.

Fix đề nghị: khi search khác rỗng thì cho thẻ tab đang chọn lấy số từ chính kết quả danh sách (peList.data.total), hoặc thêm một chữ nhỏ "trên tổng 57" cạnh dòng phân trang.

FLAG-7 [L] — hai nút cùng nghĩa "xoá tìm kiếm" nhưng phản hồi khác nhau

Nút chữ X trong ô tìm (:556-558) chỉ gọi onChange(''), tức chỉ đổi searchInput, rồi phải chờ hết 300ms gỡ nhịp mới thực sự bỏ lọc. Nút "Xoá tìm kiếm" ở khối trạng thái rỗng (:717, :911) gọi clearSearch() (:1136-1140) đặt lại cả searchInput, searchpage ngay lập tức. Cùng một hành động, một đường có độ trễ, một đường không.

Fix đề nghị: cho nút X gọi thẳng clearSearch().


TRỤC 6 — bảng có tử tế với bàn phím / trình đọc không, và mỗi tab có đủ 3 trạng thái không

Đo trên mã, từng mục:

Mục Có / Không Chỗ đo
<caption> cho bảng CÓ, cả 2 bảng, sr-only, câu nói rõ cột Hành trình dùng để làm gì :660-663, :854-857
scope="col" mọi ô tiêu đề CÓ, 7/7 mỗi bảng :666-672, :860-866
scope="row" cho ô đầu dòng CÓ (ô Mã phiếu / Mã HĐ là <th scope="row">) :740, :934
Bấm dòng bằng bàn phím CÓ — <tr onClick> chỉ là tiện cho chuột; điểm dừng bàn phím THẬT là <button> trong ô Mã, có stopPropagation nên không kích hoạt hai lần :735-751, :929-945
Không lồng nút trong nút ĐÚNG — PeUrgentChipsPeFinalizeChip chỉ dựng <span> + title, không có phần tử bấm được components/pe/PeUrgentChips.tsx, components/pe/PeFinalizeChip.tsx
Trạng thái đang tải, theo từng tab CÓ — TableSkeleton dùng một ô colSpan nên không lệch khi cột bị ẩn theo bề rộng :574-590, dùng ở :677, :871
Trạng thái lỗi, theo từng tab CÓ — khối lỗi riêng + nút "Thử lại" gọi đúng refetch() của query đó, KHÔNG gộp vào băng lỗi của hero :686-702, :880-896, nối ở :1366, :1394
Trạng thái rỗng, theo từng tab CÓ, và tách hai ca: rỗng vì chưa có dữ liệu (nút tạo mới) ⟂ rỗng vì tìm không ra (nút xoá tìm kiếm) :704-731, :898-925
Đang tải lại thì báo gì aria-busy trên <tbody> + làm mờ 60%, có tôn trọng motion-reduce :680-684, :874-878
Thanh tab đúng khuôn WAI-ARIA CÓ — role="tablist" + 4 role="tab" mang id/aria-selected/aria-controls, tabIndex luân phiên, panel role="tabpanel" + aria-labelledby; phím ←/→ cuộn vòng, Home/End nhảy biên :1323-1327, :466-480, :1346, :1123-1134
Vùng bấm đủ lớn CÓ — nút X 24×24, dòng số liệu hero cao ~32px, đều có chú thích lý do trong mã :560, :266
Tương phản chữ nền ĐẠT — tôi tự tính lại từ mã màu trong index.css, không lấy số của người làm: brand-700 #1b6aa3 + trắng = 5,77:1; teal-700 #0a7170 + trắng = 5,82:1; brand-800 #175685 trên trắng = 7,76:1; chữ text-white/90 trên nền brand-700 = 5,01:1. Cả bốn đều qua ngưỡng AA 4,5:1, và ba số đầu khớp đúng con số người làm khai.
Màu dùng có thật trong bảng màu CÓ — mọi var(--color-…) gọi trong dotVisual/STAGE_TONE (brand-500/600, teal-500/600, amberx-500, accent-500) đều được khai trong @theme (fe-user/src/index.css:5-52), và file không có dòng --color-*: initial nào nên các nấc mặc định của Tailwind (slate, red, emerald, violet-800…) vẫn còn. Không có màu ma.

Một khoản nhỏ không tính FLAG: khung cuộn ngang của bảng (:658, :852overflow-x-auto) không nhận được tiêu điểm bàn phím, nên người chỉ dùng bàn phím không cuộn ngang được bằng phím mũi tên. Vì mọi cột tràn đều đã bị ẩn theo bề rộng nên gần như không xảy ra; ghi lại cho đủ.


TRỤC 1 — khối bị owner khoanh đã biến hẳn chưa

Quét cây làm việc bằng các chuỗi nhận dạng của khối cũ (Việc của tôi, gần đây, StatCard, STAT_TONE, SlaTimer, SectionTitle, Tổng giá trị nháp, đang soạn, Sắp quá hạn, my-contracts-recent, my-pe-recent): chỉ còn hai dòng chú thích kể lại việc đã gỡ (:12, :1034) và ba trường của kiểu MyDashboard (:94, :96, :98) mô tả hình dạng dữ liệu backend trả về. Không còn một dòng giao diện nào. Đây là phân biệt dùng ⟂ nhắc-đến, không phải bỏ nửa vời.

Bằng chứng phụ mạnh hơn cả grep: fe-user/tsconfig.app.json bật noUnusedLocalsnoUnusedParameters, mà npx tsc --noEmit -p tsconfig.app.json chạy sạch ⇒ không còn import hay biến mồ côi nào của khối đã gỡ (StatCard, SlaTimer, SectionTitle, các icon Pencil/Inbox/Clock/Coins). Nếu sót, trình biên dịch đã chặn.

Hero còn nguyên: 4 thẻ giai đoạn, mũi tên nối, 6 dòng số liệu, băng lỗi + nút "Thử lại", và toàn bộ 8 đích điều hướng cũ — đối chiếu HEAD ⟂ cây làm việc đều khớp. Thay đổi duy nhất là nhãn/biểu tượng nay đọc từ mảng STAGES thay vì viết tay trong JSX.

TRỤC 4 — bản đồ trạng thái sang chấm có ca nào rơi không

Liệt kê vét cạn, không lấy mẫu:

  • Phiếu (peDot, :401-408) đi qua getPeDisplayStatus (types/purchaseEvaluation.ts:104-110). Toàn bộ 10 giá trị phase khai trong PurchaseEvaluationPhase (:21-32) đều có chỗ đáp: 1 → nháp; 7 → xong; 98 → bị trả lại; 99 → dừng; 2,3,4,5,6,10 → đang chạy. Không ca nào rơi, nhánh mặc định là "đang chạy" — an toàn, vì mọi phase còn lại đều thật sự là phase trung gian.
  • Hợp đồng (contractDot, :410-416) so thẳng với ContractPhase (types/contracts.ts:1-14): 9 → xong; 99 → dừng; 98 → bị trả lại; 1,2 → nháp; 3,4,5,6,7,8,10 → đang chạy. Không ca nào rơi.

Trục này ĐẠT về mặt "không rơi ca". Phần sai nằm ở chỗ khác — xem FLAG-2 (chấm của các giai đoạn KHÔNG phải giai đoạn của dòng đó bị gán nghĩa "chưa tới").

Quan sát KHÔNG tính FLAG (ghi để khỏi bị đào lại)

  • Không có dấu vết giả lập. Rà toàn bộ dòng thêm mới: 0 lần // Mock, 0 alert(, 0 TODO/FIXME, 0 console.. Bảy lần khớp chữ placeholder đều là thuộc tính ô tìm kiếm (dùng thật), không phải đánh dấu chỗ làm dối.
  • An toàn. Không dangerouslySetInnerHTML, không eval, không địa chỉ máy chủ hay khoá cứng trong mã. Dữ liệu hai bảng lấy từ hai endpoint đã lọc theo người dùng ở tầng backend (ContractFeatures.cs:297-303 và khối IDOR trong PurchaseEvaluationFeatures.cs) ⇒ bảng mới không mở rộng phạm vi dữ liệu người dùng được thấy so với trước, chỉ hiển thị nhiều dòng hơn của cùng phạm vi đó.
  • Tật cũ từ vòng-1 chưa xử (không tính là mới): hai ô của giai đoạn 3 vẫn lấy số từ /reports/my-dashboard nhưng dẫn sang /inbox — hai nơi đếm theo hai luật khác nhau (vòng-1 FLAG-4). Owner đã biết, để lại là lựa chọn của owner.
  • Ba trường draftsInProgress / dueSoon / draftsTotalValue trong kiểu MyDashboard không còn nơi nào đọc. Vô hại (nó tả hình dạng dữ liệu backend trả về, không phải mã chết chạy được). Nhưng đáng nói một câu: "Sắp quá hạn" nay không còn xuất hiện ở đâu trên trang chủ — đúng ý owner đã yêu cầu bỏ, chỉ ghi lại để owner biết mình đã mất cảnh báo đó.

Chỗ chịu được soi (ghi lại vì có giá trị đối chứng lần sau)

  • Gom nhãn 4 giai đoạn về một mảng STAGES khiến hero và thanh tab không thể lệch tên nhau nữa — đây là cách chữa đúng gốc cho một thứ đã trôi qua ba đợt sửa, không phải chép tay cẩn thận hơn.
  • Cách dùng lại ['pe-inbox']: giữ nguyên khoá và hàm tải, chỉ đếm bằng select ở phía quan sát. Đúng chỗ đúng cách — nếu đổi hàm tải thành trả .length dưới cùng khoá thì đã phá cache của trang Hộp thư. Trong mã có ghi rõ lý do cấm làm vậy (:1066-1069).
  • Bốn con số tương phản và toàn bộ mã màu: tôi tự tính lại và tự tra @theme, cả hai đều đứng.
  • Khung xương khi đang tải dùng một ô colSpan thay vì dựng đủ 7 ô — tránh được lỗi lệch cột khi cột bị ẩn theo bề rộng màn hình.

Sai lệch giữa lời khai và đĩa (không phải lỗi mã, nhưng phải nói)

sub-frontend-designer-dot4.md §4 khai "766 → 1.130 dòng". Đo lại trên đĩa: 1.413 dòng (wc -l), và phép cộng từ diff cũng ra đúng số đó: 766 337 + 984 = 1.413. Con số 1.130 lệch 283 dòng. Không ảnh hưởng gì tới mã, nhưng ai đọc sổ sau này mà trích lại con số đó thì trích phải số sai.

Giới hạn của lượt soi này — khai thẳng

  1. Không kiểm chứng trực tiếp trên máy chủ. Chưa triển khai, /dashboard lại nằm sau đăng nhập. Mọi kết luận về bố cục, tương phản thực tế, hành vi khi bấm đều là suy luận từ mã. Chưa có một khung hình nào được nhìn bằng mắt.
  2. Bản chạy tsc sạch của tôi yếu hơn vẻ ngoài của nó. fe-user/tsconfig.app.json không bật strict cũng không bật strictNullChecks (tôi đã tra cả tsconfig.json gốc — chỉ có references, không có cờ nào). Nên "biên dịch sạch" ở đây chứng minh được: không import thừa, không biến mồ côi, tên thuộc tính có thật. Nó không chứng minh được an toàn null. Đây là cấu hình sẵn có của dự án, không phải chuyện của diff này.
  3. Hai điểm tôi không bác được bằng cách soi tĩnh: mẹo w-full max-w-0 để cắt chữ trong ô bảng có ăn đúng ở mọi bề rộng không, và dải 4 chấm có đọc ra "hành trình" bằng mắt thường không. Cần người nhìn màn hình thật.

KẾT LUẬN

Verdict: PASS_WITH_FLAGS — 7 FLAG (0 Nặng / 2 Vừa / 5 Nhẹ).

Nói rõ vì sao không có mục nào xếp mức Nặng, để không ai hiểu là tôi hạ chuẩn cho hợp ý: tôi đã tìm đúng bốn lớp lỗi thuộc mức đó và không thấy lớp nào — dữ liệu hiển thị sai (đã đối chiếu từng tham số với controller và từng trường với DTO), lỗ hổng phân quyền (không có endpoint mới; hai endpoint đang dùng đều lọc theo người dùng ở backend), vỡ biên dịch (tự chạy tsc, sạch), và hồi quy so với HEAD (đã đo từng bản vá của vòng-1, còn nguyên cả sáu). Hai mục mức Vừa đều là chuyện đúng-sai về hành vi và câu chữ, sửa được trong vài dòng, và một trong hai là tật kế thừa chứ không phải do đợt này gây ra.

Thứ tự nên sửa: FLAG-1 (ngõ cụt trên điện thoại) → FLAG-2 (bỏ khẳng định "chưa tới") → FLAG-3 (phân trang tính từ state) → còn lại tuỳ owner.

Bốn yêu cầu của đầu bài, đối chiếu từng cái: khối bị khoanh đã bỏ hết (không sót dòng giao diện nào) · hero giữ nguyên (8 đích điều hướng khớp HEAD) · bảng chi tiết có đủ 7 cột cho giai đoạn 1 gồm cột Hành trình, có bảng hợp đồng cho giai đoạn 3 kèm nút "Tạo HĐ mới" ở trạng thái rỗng, giai đoạn 2 và 4 dẫn sang /coming-soon?stage=N (route có thật, và STAGE_INFO có đủ cả khoá '2' lẫn '4' nên không rơi về nhầm giai đoạn) · phân trang và tìm kiếm đều dựa trên tham số backend CÓ THẬT, không phải làm cho có.