[CLAUDE] Contract: KHKK 3-panel mirror Duyet NCC (GD2 het lech kien-truc) + 4 mitigation cuong-hanh

Dua man Ke hoach Ky ket HD ve dung lop kien-truc ma GD1 (PE) va GD3 (HD) dang
dung: 1 man 3-panel h-[calc(100vh-4rem)] grid [400px_1fr_360px], thay cho
list 2-panel + detail trang rieng co workflow xep chong. GD2 la mat-xich lech
duy nhat trong 4 giai-doan truoc dot nay.

BE (7/7):
- DetailDto +5 field: WorkflowVersion / EndedByLevelFinalize / FinalizeStepName
  / FinalizeLevelName / Approvals. 2 field Finalize* KHONG phai field append —
  ContractSigningPlan chi co co bool, phai DAN XUAT (lam in-memory tu cay
  workflow da nap, thay vi port 3 subquery cua PE).
- ContractSigningPlanApprovalDto de moi (truoc do 0 hit toan repo).
- StepDto +DepartmentName (nuoi chip phong panel 3 + header nhom Section 5).
- GET {id}/changelogs — policy KeHoachKyKet.Read.
- POST {id}/approval-attachments + enum +ApprovalAttachment=3 (append-only).
- approvals[] loc bo Decision=Pending de so (N) cung nghia voi (N) cua PE.
- KHONG chep khuon authz cua PurchaseEvaluationsController: file do la mot lo
  gotcha #82 (class [Authorize] tran + changelogs khong policy).

FE (x2 app, SHA256 byte-identical 8/8):
- components/khkk/{KhkkListPanel,KhkkDetailContent,KhkkWorkflowPanel}.tsx;
  KhkkWorkflowPanel doi tu pages/ sang components/.
- Panel 2: 5 Section danh so, Section 5 "Y kien cap duyet" duoi cung — khoi nay
  DOI TU panel 3 sang panel 2 (owner khoanh do trong anh); luoi 2 cot + meta
  "x/y cap da duyet · N NV tham gia" thay list phang 1 cot.
- Panel 3: banner ket-thuc-som, chip ten phong, badge Duyet thay CEO, lam mo
  buoc sau finalize, khoi File dinh kem khi duyet, Lich su duyet, Lich su
  thay doi. Chua chon phieu -> hop dashed y PE (QD owner).

4 mitigation cuong-hanh (owner chot panel 1 = PipelineTreePanel; chon HINH
khong phai chap-nhan VO):
- M-1 fallback !canPe -> danh-sach KHKK phang. Khong co no, user chi co quyen
  KHKK (vd nguoi duyet nhom N5) MAT TRANG danh-sach du API van cho doc —
  display-layer khoa authz-layer dang mo, mat con lai cua #82.
- M-2 onOpen thanh callback tu host; mac-dinh giu navigate cu => 0 hoi-quy
  trang PE/GD3.
- B1 stage2Content di CAP voi stage2Count qua conditional-spread:
  khong truyen => stage2 van nhan groups => trang PE GIU NGUYEN 8 ngan N1-N8.
- R-11 search/loc di server-side, KHONG loc-tren-cay; giu dong canh-bao
  "Cay tai 200 phieu moi nhat".

Chong hoi-quy da do: build 3/3 PASS · acceptance tinh 20/20 x 2 app ·
mirror SHA256 8/8 · 0 hit path cam (components|pages)/(pe|contracts) ·
KHONG ghi filter/view/group vao URL (chong tai-sinh bug S155 sidebar sang
nham leaf) · giu set-state-in-render :97-101 (dung khuon React, refactor
thanh useEffect se tai-hien va-5).

Con no, khai thang:
- E-7 bang "cot nao chet, chuyen di dau" (8 cot + 6 KpiCard -> card 400px)
  THIEU: lane FE chet o luot tra ve, ly-do bo cot mat theo. Phai soi diff
  bang mat truoc UAT, neu khong dung ca M-7 mat-chuc-nang-im-lang.
- Test 643/644. 1 FLAKY Create_DifferentEvaluationType_DoesNotAffect_Other
  (full-suite 2->1->1, chay rieng 0/0/0; PeWorkflowAdminTests tao
  new SqliteDbFixture() rieng x6 test). Baseline tren HEAD sach CHUA do
  (git worktree fail duong-dan Dropbox) => CHUA duoc noi "flake co san".
- 5 acceptance RUNTIME cho UAT: approvals co dong Tra lai + ten nguoi (DB dev
  0 hang KHKK) · UAT fe-user:8080 an-danh · deep-link fe-admin:8082 (menu
  KHKK bi an trong ADMIN_HIDDEN_KEYS) · negative-control go fallback canPe ·
  khoi file tu-an khi rong.
- P3-12 4 return-mode = HOAN, khong phai buoc-khac: tien-de "KHKK 3 tram ngan"
  do lai la SAI (DbInitializer:554 = 3 Buoc x <=2 Cap, seed tu ghi "sao tu
  workflow Duyet NCC prod").

Spec + 6 artifact phan-bien: .claude/workflows/runs/2026-08-04-S171-khkk-ui-mirror-pe/

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-04 19:48:38 +07:00
parent f151320780
commit 998ea559f3
21 changed files with 4581 additions and 3179 deletions

View File

@ -162,6 +162,27 @@ export type BuildStagesOptions = {
/** GĐ1 tự vẽ (trang Duyệt NCC truyền khối leaf phiếu CŨ vào đây). */
stage1Content?: ReactNode
stage1Count?: number
// ── [S171 — mitigation B1 + M-2, owner chốt panel 1 KHKK = cây] ─────────────
/**
* GĐ2 tự vẽ (trang Kế hoạch ký kết truyền khối CARD phiếu của chính nó).
* 🔴 TRADE-OFF ĐÃ KHAI: `PipelineStageFolders.tsx:285-287` đặt `content` TRƯỚC `groups`
* trong chuỗi tam-nguyên ⇒ truyền cái này thì 8 ngăn N1-N8 KHÔNG vẽ nữa (im lặng, không
* lỗi). Chấp nhận được VÌ lối vào nhóm giữ ở `Select` nhóm của khối filter panel 1.
* ADDITIVE: `PurchaseEvaluationsListPage` không truyền ⇒ trang PE/GĐ3 GIỮ NGUYÊN 8 ngăn.
*/
stage2Content?: (items: KhkkListItemDto[]) => ReactNode
/**
* 🔴 BẮT BUỘC đi kèm `stage2Content` — `stageCount()` (`PipelineStageFolders.tsx:95-97`)
* đọc `count ?? 0` khi có `content`, thiếu là badge về 0 trong khi folder đầy phiếu.
* Mặc định (không truyền) = số phiếu của gói, KHÔNG bao giờ để rơi về 0.
*/
stage2Count?: (items: KhkkListItemDto[]) => number
/**
* [M-2] Bấm leaf/card KHKK ⇒ HOST xử lý (chọn vào panel 2 của màn 3-panel) thay vì
* `navigate('/khkk/:id')` rời trang. KHÔNG truyền ⇒ giữ NGUYÊN hành vi cũ (navigate)
* cho mọi call-site khác — 0 hồi-quy trang Duyệt NCC / GĐ3.
*/
onOpenKhkk?: (id: string) => void
}
/**
@ -208,7 +229,8 @@ export function usePipelineStages() {
secondary: k.peTenGoiThau,
badgeLabel: KHKK_PHASE_LABELS[k.phase],
badgeClass: KHKK_PHASE_BADGE[k.phase],
onOpen: () => navigate(`/khkk/${k.id}`),
// [S171 M-2] Host truyền callback ⇒ chọn tại chỗ; không truyền ⇒ navigate như cũ.
onOpen: opts.onOpenKhkk ? () => opts.onOpenKhkk!(k.id) : () => navigate(`/khkk/${k.id}`),
})
const khkkLeaves = khkkItems.map(toKhkkLeaf)
@ -265,7 +287,14 @@ export function usePipelineStages() {
const stage2: PipelineStage = {
n: 2,
leaves: khkkLeaves,
groups: khkkGroups,
// [S171 B1] `content` ⇒ thân folder do host vẽ (card phiếu KHKK) và `groups` bị bỏ
// qua; `count` PHẢI đi cùng, mặc định = số phiếu của gói (không để rơi về 0).
...(opts.stage2Content !== undefined
? {
content: opts.stage2Content(khkkItems),
count: (opts.stage2Count ?? (its => its.length))(khkkItems),
}
: { groups: khkkGroups }),
locked: !linkage.canKhkk,
failed: linkage.khkkFailed,
loading: linkage.khkkLoading,