Compare commits

..

3 Commits

Author SHA1 Message Date
43e1bd7bc2 [CLAUDE] Contract: UAT S174 dot-B — be style PE sang KHKK (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m37s
Nguon: spec runs/2026-08-05-S174-khkk-style-audit/spec-khkk-style-dong-bo-05-08-2026.md
(ensemble /fable-clone frontend-designer 5 lane). Owner S175: "KHKK theo khuon PE".
Mirror 7 CAP file, sha256 khop 7/7. Build XANH ca 2 app, 0 error TS.

=== B1 panel Quy trinh duyet (KhkkWorkflowPanel) ===
X2 GO card-accent -> panel TRAN khuon PE (:345).
  🔴 GO HAN CLASS, khong de border-0/shadow-none: .card-accent (index.css:112) nam NGOAI
  @layer nen thang utility Tailwind v4 (gotcha #66).
- Tieu de h3 tran + bo icon-chip; so hieu WF padStart(2,'0') => v01 (PE :350).
- Banner ket-thuc-som violet -> EMERALD (PE :360-377). PE danh violet RIENG cho
  "Duyet thay CEO"; dung violet ca 2 cho la tron 2 nghia vao mot mau.
- Chip PHONG vien-xam-nen-trang -> emerald dac (PE :406-410).
- Nut lucide -> glyph '✓ Duyet' / '← Tra lai' (PE :540); nut Xoa xam -> DO (PE :574).
- File dinh kem: +border-t pt-4 +h3 +nut <Eye> xem truoc. Route BE DA CO
  ContractSigningPlansController.cs:231 (/view, cung hinh PE :317).
  Nhan "Tai xuong" chuyen vao title= chu KHONG bo — giu chuoi do trong bundle
  de phep verify prod con control-duong soi duoc.
- Lich su duyet: bo pill dem -> (n) trong ngoac; the bg-slate-50/60 -> bg-white p-3 text-sm;
  moc-gio day phai bang justify-between.
  🔴 Badge 2-nga -> 3-NGA: truoc day MOI thu khong phai Reject deu to emerald => "Tra lai"
  (amber o PE) bi ve xanh nhu da-duyet, doc NGUOC nghia. toPhase da co san (khkk.ts:307).
- Lich su thay doi: BO GAP, render thang (PE PeDetailTabs.tsx:787-794).
  🔴 BAT BUOC bo luon `enabled: showChangelog` cua useQuery: giu lai thi khoi luon hien
  nhung query KHONG BAO GIO chay, va no hong duoi dang "Chua co thay doi nao duoc ghi."
  — tuc trong y het phieu chua tung sua, KHONG phai trong nhu loi. Doi lay +1 request/phieu,
  dung gia PE dang tra.
- Micro-copy: Huy / tuy chon; nhan O NHAP -> "Ghi chu" (PE :869).
  🔴 CHI doi ten TRUONG — chuoi "Y kien cap duyet" o dong tro-giup la TEN THAT cua muc dich
  den trong phieu (Section 5, Mig 26), doi no la tro nguoi dung toi mot muc khong ton tai.
- KHONG be chip "NS PRO"/"NS CCM" (2 co do mo quyen sua ngan sach per-goi-thau, khai niem
  KHKK khong co). GIU moc chu-ky KHKK (cho KHKK tot hon PE).

=== B2 noi dung phieu (KhkkDetailContent) — 6 P0 ===
- 2 bang: table-fixed + <colgroup> + min-w-[880px] trong overflow-x-auto. Bang co gian
  tu-do truoc day de cot tien nhay be rong theo do dai ten NCC => 3 cot so khong thang hang.
- O RONG -> em-dash MO (text-slate-300) gom vao helper orDash/moneyOrDash: truoc day em-dash
  thua-huong dung mau chu that => luot bang thi "—" trong y nhu mot GIA-TRI.
  moneyOrDash rieng vi formatVnd tra CHUOI "—" nen khong to mo duoc.
- empty-block -> text-sm italic text-slate-400 (3 cho).
- emoji 🔒 -> icon lucide <Lock>: emoji render khac nhau theo font he dieu-hanh va KHONG an
  currentColor nen luon lech tong voi chu amber ben canh.
- thong nhat text-xs ca hang bang; +hover:bg-slate-50/60 cho <tr>.

=== B3 panel danh sach (KhkkListPanel + KhkkWorkListPanel + PipelineTreePanel) ===
- DAU COT: che-do CAY truoc day di thang vao than, khong tieu-de/so dem => cot "cut dau",
  lech ca voi man Thao tac (Tier header) LAN voi chinh che-do phang (co dem o chan).
  Mirror khuon Tier.
- Gop 2 o tim chong nhau: +prop hideSearch cho PipelineTreePanel (MAC DINH giu nguyen —
  4/6 host kia khong truyen, 0 doi hanh vi). O trong cay KHONG bao gio dung toi vi o ngoai
  di server-side va go vao la chuyen han sang flatMode => cay bien mat.
- Thang chu 4 nac (13/11/10/9) -> 3 nac chuan 13/11/10.
- 🔴 WCAG: text-slate-400 = 2.56:1 tren nen trang, DUOI nguong AA 4.5:1 — nang len
  slate-500 (4.76:1) cho 7 dong MANG THONG TIN (6 note/empty-state + 1 so dem).
  GIU slate-400 cho 2 cho TRANG TRI (icon Search, dau cham phan cach) — WCAG khong doi
  tuong phan cho noi dung trang tri.
- Nut phan trang ky-tu tran ←/→ -> icon ChevronLeft/Right + aria-label: mui ten dang chu
  doi hinh theo font he dieu-hanh va KHONG co ten cho trinh doc man hinh.
- Chip nhom: inline + co chu khac dong chua => hop chip cao khac chu ben canh nen tut
  baseline; inline-flex items-center leading-none ghim thang hang.

=== B4 host (KhkkCreatePage) ===
- Grid 260/320 DA land tu dot A — chi con COMMENT stale ghi 300px, da va.
- 🔴 Va comment SAI SU THAT (L3 H-5): comment khai Panel 1 co "nut hanh-dong dinh day"
  nhung grep -c Button KhkkWorkListPanel = 0 (control-duong PeListPanel = 8). Do la mo ta
  CUA PE be nguyen sang => tu khai mot thu chua dung.

=== 2 component DUNG CHUNG — mo rong kieu MAC-DINH-GIU-NGUYEN ===
AttachmentPreviewDialog +basePath? (mac dinh /purchase-evaluations) => 6 call-site PE
KHONG phai sua dong nao (module da golive; bat sua het call-site cu = tu de rui-ro o cho
dang chay tot). PipelineTreePanel +hideSearch? y het. Da verify: 0 call-site PE bi dong.

=== CON NO ===
🔴 CHUA soi mat tren prod 375/1366/1440 — ca 5 lane deu NO-SCREENSHOT (dev-rig chan:
chi :8080 LISTENING, API :5443 khong co, trang sau ProtectedRoute — gotcha #3). Moi ket
luan la DOC MA, khong phai da nhin.
Dot C3 (Field to brand cho MOI value) + dot D (O3/O4/O6/O7/O8) chua lam. O3 con cho owner
xac nhan cach doc "sum tong" — do la tien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:30:13 +07:00
c61e28f833 wal: flush 20260805T1854 2026-08-05 18:54:08 +07:00
213eec07ed wal: pause 2026-08-05 17:06:33 +07:00
23 changed files with 793 additions and 366 deletions

View File

@ -1,41 +1,33 @@
# WAL — auto-generated, không sửa tay # WAL — auto-generated, không sửa tay
updated: 2026-08-05 | session: S174 (phiên-LOGIC L11, window 2) | branch: main updated: 2026-08-05 | session: S175 (phiên-LOGIC L11, window 3) | branch: main
goal: nối mạch sau /pause. Vá 5 điểm treo + trả lời anh. Reconcile §3 đã sửa 3 chỗ lệch (dưới). goal: UAT UI module KHKK theo 9 ảnh anh khoanh. Đợt A ĐÃ ship + deploy VERIFIED 2-vế. Cửa này = đợt B → hỏi câu "sum tổng" → đợt D.
chain: chain:
- [x] bootstrap: Sàn-3 SẠCH 5/5 · scaffold session-11 · tick 46→47 squash-benign · test 644 PASS/0 Fail - [x] nối mạch /tiep: Sàn-3 5/5 · reconcile 5 chỗ lệch · tick 47→48 · `_tiep-1.md` cân sổ ORPHAN-L
- [x] 5 vòng / 11 vai — tooling PWF-10 · harvest GATE-FAIL-6 · lead-stale 11 FLAG(SÀN) · lead-gap 4 · ring1 78Đ/4T · ring2 15Đ/3T · eval MIXED-15 · refine 1-action/15-bác · audit 50Đ/7T · ctx-audit TRUOT-7 · ring4 NO-OP-có-khai - [x] 5 commit ship + 3 đợt deploy XANH (verify byte-level: hash ĐỔI ∧ chuỗi mới CÓ, 2 vế): `14a917f` `3f339c0` `f86f30d` `d0453ba` `a1b9cbb`
- [x] cổng C2 @pause KÊU (A\B=9) → lead seed 9 diary + 1 erratum → A\B=∅ (verify: mọi vai grep S173 ≥1) - [x] anh BÁC chỗ đặt cột 2-tầng → REVERT `PurchaseEvaluationWorkspacePage` ×2 về ĐÚNG BYTE `1b3bcaa` (verify `git show` + grep 0 hit)
- [x] _mind-s-11 MIND-0+MIND-1; mind-check `dat=11 TRUOT=0` exit 0 · _context PAUSE-1 + §(c) 16 file - [x] ensemble `/fable-clone frontend-designer` `wf_016b1bed-b85` — 5 lane / 5 xong / 0 chết / 755K tok; spec 3-mục + `audit-synthesis.md` ĐÃ ghi (hết orphan dạng-1)
- [x] 🔄 RECONCILE-§3 @S174: MIND-2 ĐÃ chèn @037fe63 sau ctx-curator `SUA-9` (lead vá đủ 9) — mind-check nay 3 block (2,1,0) dat=11/TRUOT=0. WAL cũ ghi `[!]` chờ-chèn = LỆCH, đã sửa. - [x] đợt A ship `a1b9cbb`: vá 401 tải file · vá `<lg` ngõ cụt mobile · trả nút duyệt mobile · gỡ SoonChip (3 ca 3 cách) · gỡ nút Từ chối · grid 260/320
- [x] tick 47→48 clean @S174 · dòng-nợ: light 2/6 deep 8/15 ok | tràn-bộ-nhớ 1 | run-chưa-gom 1 | pull-cách 14 ngày - [x] cổng C2 @PAUSE-2 KÊU (A\B={frontend-designer}) → lead seed 1 entry → A\B=∅, sổ 16.373→19.869 B (control-dương: S173 có ở 10 sổ)
- [!] ring2-audit/MEMORY.md = 26.440 B > autoinject_cap 25.600 (dư 840 B) — DO CHÍNH LƯỢT SEED S173. A1 (rút 3 record cũ → archive/2026-07.md, còn 20.375 B) có payload sẵn, harness-audit chấm ĐẠT, CỐ Ý hoãn khỏi cửa dừng - [x] deploy `a1b9cbb` VERIFIED 2-vế ×2 app @S175: hash ĐỔI (admin `BXwdUVmD``Dlhi3eF1` · eoffice `DdcCH2oZ``B9PVjJnU`) ∧ literal gỡ `Từ chối kế hoạch (khoá phiếu)`=0/0, control-dương `Tải xuống`=10/10
- [ ] `STATUS:473` Gotchas 86→87 (đầu chuỗi B1 ⇒ 3 bề mặt sai theo) - [ ] đợt B (32 mục bê style PE): panel duyệt 13 · nội dung 6 P0 + 8 P1 · panel list 13 delta (có `slate-400`=2.56:1 dưới WCAG) · host
- [ ] gạch 5 slot (68)-(72) trong HANDOFF (đã ratify `6730eb4` mà sổ chưa lật — còn sống ở :16 :18 :20 :23 :25) - [ ] đợt D: O6 bấm-card-PE-giữ-màn · O3 hạng-mục N-dòng + SUM (**0-migration**) · O4 shopdrawing (**FE-only**) · O7 nút edit · O8 cây 2 cấp
- [ ] đưa `ROTATE mật-khẩu prod vrapp` trở lại HANDOFF (ctx-audit F-1 HIGH — rơi khỏi MỌI bề mặt sống) - [ ] 🔴 HỎI ANH trước khi viết BE cho O3: cách đọc "sum tổng" — đây là TIỀN (lane L5 dặn)
- [ ] re-stamp Carry 14 slug (HANDOFF:38 còn đóng dấu @S172 ⇒ bỏ tiếp = streak 12; ctx-audit F-5: lớp mềm mù hoàn toàn nghĩa vụ này) - [ ] thăng luật E4 vế ➊ lên khối auto-inject `harness-eval/MEMORY.md ## Luật cứng` (đang 3 bullet, luật nằm dưới vùng planner mù)
- [ ] slot (70) MỞ LẠI — vá anchor `^\*\*` phải neo CẢ `**`/`##`/`###` (3 khối mù `:27` `:68` `:102`) - [ ] ghi synthesis cho run-folder **S173** `2026-08-05-S173-bookend-open` (vẫn ORPHAN, 0 flat synthesis)
- [ ] monthly drift-audit (quá hạn 4 ngày; ring1 phân xử CHƯA chạy, lần cuối `881783c` 07-01) - [ ] 5 điểm governance đầu phiên CHƯA ĐỘNG: `STATUS:473` 86→87 · gạch 5 slot HANDOFF · `ROTATE vrapp` · re-stamp Carry 14 slug · slot (70) anchor
- [ ] 🔄 RECONCILE-§3 v2 (ctx-verifier cờ-5 sửa lead): việc = **ĐỌC/ÁP 4 + KÉO 2**, KHÔNG phải "kéo 6". 4× `2026-08-04-*upgrade-pack-phased-*` ĐÃ về `broadcasts/inbox/ai_infra/` @`3a6eb92` (chỉ chưa có adap-report) · 2× `2026-08-05-*loi-do-luong-phan-1/2` **0 hit toàn đĩa** = chưa kéo thật. Kênh se=0. (lead dò sai vì soi `inbox/<file>` — inbox lồng theo NGƯỜI GỬI `inbox/<from>/`) - [ ] `ring2-audit/MEMORY.md` 26.440 B > cap 25.600 — A1 treo từ PAUSE-1
- [ ] 🔴 MỚI (ctx-verifier cờ-4 — rơi im lặng qua 2 phiên): E4 vế ➊ CHƯA land. `agent-memory/harness-eval/MEMORY.md` `## Luật cứng` **đúng 3 bullet**, thiếu luật *"0-hit CẤM rời tay khi chưa có control-dương"* — luật ĐÃ có trong THÂN nhật-ký `:9` (tự rút @S172) nhưng **không được thăng lên khối auto-inject** ⇒ không tới vai lúc spawn. Chủ = **LEAD** (`harness-refine` §10). Payload: `runs/2026-08-05-S172-bookend-close/harness-refine-return.md` §1 - [ ] kéo 2 thư `2026-08-05-Governance-loi-do-luong-phan-1/2` — 🔄 @S175 nay ĐÃ CÓ ở `AI_INFRA/broadcasts/outbox/all/` (trước = 0 hit); 4× `upgrade-pack-phased` đã ở `inbox/ai_infra/`, chỉ thiếu adap-report
- [ ] 🔄 RECONCILE-§3 (MỚI, không có trong WAL cũ): run-folder `2026-08-05-S173-bookend-open` = **ORPHAN** — 11 artifact ~245 KB + run.md giàu NHƯNG 0 file `*-synthesis.md` phẳng ⇒ Sàn-3 ① KÊU. Cần synthesis @bookend-close. - [ ] monthly drift-audit quá hạn · dọn 9 dir rỗng `runs/.../agent-memory/*` @session-end
- [ ] dọn 9 dir rỗng `runs/.../.claude/agent-memory/*` @session-end sau sub cuối (harness tạo lúc spawn, đơn-điệu tăng — ghi CƠ-CHẾ không ghi SỐ)
- [x] 🆕 VIỆC ANH GIAO S174 (ngắt mạch vá-governance): 7 chỉnh UI KHKK/PE qua 6 ảnh khoanh đỏ + 4 câu trả lời — commit `14a917f` 16 file, push `1b3bcaa..14a917f`. Build 2 app PASS · mirror SHA256 8/8 · byte-level bundle local xác nhận. Chi tiết ở body commit. next: đợt B (bê style PE) → hỏi anh câu "sum tổng" TRƯỚC khi viết BE cho O3 → đợt D → [đuôi, đừng rơi] thăng luật E4 ➊ · synthesis S173 · 5 điểm governance
- [x] deploy `14a917f` XANH (~5 phút, poll 15/28). Bundle prod ĐỔI cả 2: admin `Ajv-MaCz``DpZjuFBn` · eoffice `YsXRkBSR``DU_NaPIg`; 5/5 chuỗi mới HIT ở CẢ HAI; `Tất cả nhóm` VẮNG (đúng). Trang chủ http=200 ×2. 🔴 KHÔNG có `GITEA_TOKEN` ⇒ verify bằng grep bundle prod (#77) thay vì Actions API — 2 vế hash-ĐỔI + chuỗi-CÓ mới đủ, mỗi vế lẻ đều nói dối được
- [x] 🔄 VÒNG 2 S174 — anh BÁC chỗ đặt: *"bỏ phần này đi chỗ duyệt NCC đã golive và đã tốt rồi, ý tao là đang muốn mày làm giống giao diện thao tác của Duyệt NCC"*. Commit `3f339c0` (amend): REVERT `PurchaseEvaluationWorkspacePage` ×2 về ĐÚNG BYTE `1b3bcaa` + `git mv` panel `components/pe/PeKhkkSidePanel``components/khkk/KhkkWorkListPanel` + dựng `/khkk/create` thành HOST 2-PANEL (panel-1 2 tầng callback-driven, panel-2 form|xem). Deploy XANH poll 17: admin `DpZjuFBn``CcypDu1E` · eoffice `DU_NaPIg``6q1cAHcJ`; 5/5 chuỗi mới HIT ×2, control-ÂM `xl:grid-cols-[320px_1fr_320px]` VẮNG ×2 (chứng revert sạch tới prod)
- [!] 🔴 BÀI HỌC vòng này — 2 lỗi CÙNG HỌ "kết-quả trông thành-công nhưng không đo thứ mình tưởng", cần cân nhắc lên gotcha/memory:
(a) **"làm giống màn X" ≠ "sửa màn X"** — anh khoanh đỏ TRÊN màn PE để chỉ NỘI DUNG muốn có, lead đọc thành CHỖ ĐỂ SỬA ⇒ chèn nhầm cột vào màn đã golive. Ảnh sau (anh 7) mới lộ chỗ đặt thật.
(b) **`git add` abort TRỌN lệnh khi có 1 path đã bị `git mv` xoá** ⇒ 6 file sửa nội dung KHÔNG được stage mà `git commit` vẫn báo thành công; tín-hiệu duy nhất = dòng `2 files changed, 0 insertions(+), 0 deletions(-)`. Bắt được nhờ đọc SỐ + verify `git show HEAD:` thay vì tin chữ "thành công". Cùng họ với `539ms`-build và 3 probe hỏng đầu phiên.
- [ ] 🆕 nợ đã KHAI với anh, chưa làm: trang chi tiết KHKK trên mobile mất nút Gửi duyệt/Xóa phiếu (gỡ cùng khối Quy trình duyệt) — mở lại bằng `lg:hidden`. 🧊 Nợ "preselect `?peId=`" ĐÃ TỰ TIÊU: panel nay callback-driven, bấm PE nạp thẳng vào form, không còn điều hướng.
next: chờ poll deploy `b4d42pfzu` → báo anh kết quả → quay lại vá 5 điểm governance treo (STATUS:473 → HANDOFF 5 slot → vrapp → Carry re-stamp → slot 70)
verify: verify:
- `sed -n '473p' docs/STATUS.md | grep -c 'Gotchas | \*\*86\*\*'` → 1 = CHƯA vá (🔴 literal cũ `'Gotchas | 86'` KHÔNG khớp vì text là `**86**` bold — 0-hit đọc nhầm thành đã-vá; ground-truth `grep -cE '^### [0-9]+\. ' docs/gotchas.md` = 87) - `curl -s https://admin.solutions.com.vn/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js'` → khác `index-Dlhi3eF1.js` = đợt SAU đã tới (mốc @S175)
- `grep -c 'ROTATE' docs/HANDOFF.md` → 0 = vrapp còn mất (control-dương: `grep -c ZZTEST` → 3) - `grep -c '<SoonChip' fe-admin/src/components/pipeline/PipelineStageFolders.tsx` → 0 (control-dương: `grep -c 'SoonChip'` → 2, chỉ còn trong comment)
- `grep -c 'Carry @S172' docs/HANDOFF.md`1 = chưa re-stamp - `grep -c "responseType" fe-admin/src/components/khkk/KhkkWorkflowPanel.tsx`2 = A1 đã vá
- `ls .claude/workflows/runs/2026-08-05-S173-bookend-open/*-synthesis.md` → no match = S173 CÒN orphan
- `wc -c .claude/agent-memory/ring2-audit/MEMORY.md` → 26440 (>25600 = còn nợ A1) - `wc -c .claude/agent-memory/ring2-audit/MEMORY.md` → 26440 (>25600 = còn nợ A1)
- `ls .claude/workflows/runs/2026-08-05-S173-bookend-open/*-synthesis.md` → no match = còn orphan - `sed -n '473p' docs/STATUS.md | grep -c 'Gotchas | \*\*86\*\*'` → 1 = chưa vá (literal có `**` bold)
- `ls .claude/sessions/session-11/` → _context-s-11.md · _mind-s-11.md · _pause-1.md · _tiep-1.md - `ls .claude/sessions/session-11/` → _context · _mind · _pause-1 · _tiep-1 · _pause-2

View File

@ -45,6 +45,12 @@
- **Lucide alias-dodge:** icon từ export-aggregation-line (`SendHorizonal`) / mojibake token (`网络Placeholder` autocomplete) → dùng proven-safe export (`Send`), node-check icons trước build. - **Lucide alias-dodge:** icon từ export-aggregation-line (`SendHorizonal`) / mojibake token (`网络Placeholder` autocomplete) → dùng proven-safe export (`Send`), node-check icons trước build.
## Activity log ## Activity log
- **S174 (2026-08-05) AUDIT style KHKK(GĐ2) ⟂ Duyệt NCC(GĐ1) — ensemble 5 lane CÙNG 1 VAI qua `/fable-clone`, 5/5 xong 0 chết, 755K tok/147 tool-use/~17.** *(Entry on-behalf bởi lead @pause cổng C2 — VERIFY từ 5 sub-file trên đĩa + tự re-đo 2 claim nặng nhất.)* Run `wf_016b1bed-b85`, artifact `runs/2026-08-05-S174-khkk-style-audit/sub-frontend-designer-L1..L5.md` (34+41+30+33+32 KB) + `spec-khkk-style-dong-bo-05-08-2026.md`. **Lăng-kính:** L1 panel-duyệt · L2 panel-list · L3 host · L4 cây · L5 nội-dung. **Verdict:** L1 15 lệch (13 bê-thẳng) · L2 CHƯA-PASS 13 delta + 3 trục rubric đỏ · L3 KHỚP-MỘT-PHẦN · L4 SHIP-SPEC-READY · L5 6 P0 + 8 P1 + 3 P2.
🔴 **3 phát hiện owner KHÔNG nêu mà nặng hơn phần lớn danh sách owner nêu:** (a) **LỖI THẬT** `KhkkWorkflowPanel:501` tải file bằng `<a href>` trần ⇒ JWT chỉ gắn qua interceptor axios (`lib/api.ts:18`) ⇒ endpoint `[Authorize]` ăn **401, nút không chạy** — lead verify bằng đối-chứng `responseType:'blob'` ở PE `:331`; (b) vỏ `card-accent` bọc panel KHKK ⟂ PE panel TRẦN = thứ làm 2 bên "nhìn khác hệ" TRƯỚC mọi mục owner kê; (c) `<lg` màn Thao tác `<main hidden lg:block>`**mobile không tạo được phiếu** — regression do CHÍNH LEAD gây ra cùng phiên.
🔴 **BÀI LỚN NHẤT — lane BÁC 2 giả-định của lead, lead tự verify thì lane ĐÚNG:** `O3` hạng-mục N-dòng **KHÔNG cần migration** (`ContractSigningPlanLineConfiguration.cs:37-38` unique index **3 CỘT** `(PlanId,SupplierId,CatalogEntryId)` ⇒ 1 NCC đã được phép N dòng × N hạng mục từ Mig 71) · `O4` shopdrawing **FE-only 0-BE** (rail `dossierItemId` đủ 6 tầng từ Mig 69). Lead đã báo owner "O3 nặng nhất cần migration" — **SAI**, đã đính chính. ⇒ **Trước khi tuyên một việc là "nặng/cần migration", ĐỌC EF configuration + index; đừng suy từ hình-dạng UI.** Đây đúng khuôn `máy rộng, UI hẹp` (owner S164): schema đã tổng-quát sẵn, chỉ UI đang khoá 1-1.
🔸 **Chỗ CỐ Ý không bê:** chip "NS PRO"/"NS CCM" (`PeWorkflowPanel:457-463`) — 2 cờ `canEditProBudget/canEditCcmBudget` mở quyền sửa **ngân sách per-gói-thầu** (`PeWorkItemBudgets`, Mig 50), **khái niệm KHKK không có** ⇒ bê sang = vẽ nhãn quyền không tồn tại. Và chỗ KHKK **TỐT HƠN** PE (mốc chữ-ký + in đậm người đã ký) ⇒ **GIỮ, đừng hạ xuống cho "đồng bộ"**.
🔴 **NO-SCREENSHOT ×5 lane, khai thẳng:** dev-rig chặn (chỉ `:8080` LISTENING, API `:5443` không có, trang sau `ProtectedRoute` — gotcha #3) ⇒ mọi kết luận là **đọc mã, KHÔNG phải đã nhìn**. Thay bằng: đo class trên đĩa + `sha256sum` mirror + **tự tính tương-phản WCAG** (bắt được `slate-400` = **2.56:1** cho 6 dòng chữ mang thông tin). Người land BẮT BUỘC soi 375/1366/1440 trên **prod sau deploy**.
🧪 **Luật C2 (ép ghi khung rỗng lượt 1-2 TRƯỚC khi đọc) có RĂNG THẬT:** run trước cùng khuôn 3/5 lane chết cháy-lượt; run này **0/5 chết**. Cùng model, cùng harness, khác **thứ tự ghi**. Tag [s174, ensemble-5-lane-1-vai, fable-clone, audit-khkk-vs-pe, 401-a-href-khong-mang-jwt, card-accent-ngoai-layer-gotcha66, lane-bac-gia-dinh-lead, may-rong-ui-hep, no-screenshot-khai-that, c2-khung-rong-0-lane-chet].
- **S162 (2026-07-30) Cây 4 folder GIAI ĐOẠN dưới từng gói thầu — 3 file MỚI + 3 trang, MIRROR đủ 2 app, build PASS ×2.** Owner (screenshot khoanh đỏ dải 4 GĐ → cây trái trang Duyệt NCC) + AskUser chốt: folder đặt DƯỚI TỪNG GÓI THẦU · áp cả trang GĐ khác. Nhãn VERBATIM "Duyệt NCC · Kế hoạch Hợp đồng · Duyệt Hợp đồng · Hợp đồng cứng" (GĐ2 CỐ Ý khác nhãn strip `PePipelineStrip` = lời-mới-nhất-thắng; KHÔNG sửa strip/menu). **3 file mới:** `components/pipeline/PipelineStageFolders.tsx` (thuần trình bày; `<details>` controlled `useState(n===currentStage)`; prop `content` cho GĐ1 giữ leaf phiếu cũ; `locked`/`failed`/`loading`/`hideEmpty`) · `hooks/usePipelineStages.ts` (2 query index key MỚI `pipeline-khkk-index`/`pipeline-contract-index` pageSize 200 — `PagedResult.cs:20` KẸP ≤200 không 400; `buildStages(peItems)` nối GĐ2 qua `khkk.purchaseEvaluationId`, GĐ3 qua `pe.contractId`, GĐ4 = HĐ đó có `hasSealedCopy`) · `components/pipeline/PipelineTreePanel.tsx` (panel cây trái cho trang GĐ2/GĐ4; `buildTree` RIÊNG, KHÔNG đụng memo `yearGroups` trang PE). **Wire:** PE list (`currentStage=1`, khối `<li>` phiếu giữ NGUYÊN TỪNG BYTE kể cả thụt lề ⇒ diff chỉ 2 mẩu bọc = bằng chứng không đụng behavior) · KhkkListPage (=2) · HardCopiesPage (=4), 2 trang sau bọc lưới `lg:grid-cols-[19rem_minmax(0,1fr)]` + `min-w-0` + `<table>` bọc `overflow-x-auto` + KPI `lg:grid-cols-6``xl:`. **Permission 2 tầng (S118/#82):** gate TRƯỚC fetch, `enabled:false` ⇒ 0 rác 403 **VỚI ĐIỀU KIỆN gate = ĐÚNG key policy của endpoint** *(reviewer F-1 @S162 bắt, lead sửa cùng lượt: GĐ2 `/contract-signing-plans` = `KeHoachKyKet.Read`, `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa/OR-con ⇒ OR key con sẽ fetch rồi ăn 403 khi role thiếu key root; gate đã đổi về `['KeHoachKyKet']`. GĐ3/4 `/contracts` `[Authorize]` trần nên OR key hiển-thị vô hại)*. ⚠️ menu-tree fallback quyền cha CHỈ khi menu con KHÔNG có row riêng (`GetMyMenuTreeQuery.cs:66`) — seeder TẠO row `Ct_*` (`DbInitializer.cs:2168-2176`) ⇒ `Ct_*` thực tế KHÔNG kế thừa `Contracts` *(câu "Ct_* kế thừa Contracts" bản đầu entry này SAI — reviewer F-8, đã sửa)*. **+5 fix hậu-review cùng lượt (lead áp):** F-1 gate-policy-key · F-3 leaf ngoài-index nhãn "(ngoài chỉ mục 200 — mở để xem)" tách nghĩa "(chưa cấp mã)" · F-4 index giữ `total` ⇒ cờ `truncated` khai cửa-sổ-200 ngay trên cây (footer folder + notice panel) · F-6 `hideEmpty` KHÔNG nuốt folder failed/loading · F-7 upload bản cứng invalidate thêm `pipeline-contract-index`. Giới hạn khai thẳng: F-2 phiếu liên-danh N-HĐ chỉ nối được HĐ[0] qua `pe.contractId` (chờ W5 link KHKK→HĐ) · F-5 lưới 19rem 2 trang chưa render thật (harness chỉ import 2 component) — nghiệm thu prod. **Evidence:** `.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/sub-frontend-designer-0.md` + `ship-synthesis.md` · SHIP `bfc7b79` · cicd PASS 5/5 Run #432. 🔸 *H2 F-03 @S162: `ship-synthesis` trích `VERDICT: SHIP-READY` như verbatim của tao nhưng sub-file **0 hit** chuỗi đó (kết bằng "TỰ ĐÁNH GIÁ CÒN HỞ", không END-line) — nội dung khớp đĩa nên KHÔNG phải bịa, nhưng từ nay **mọi `sub-*.md` PHẢI kết bằng `END <slug> — VERDICT=<…>`*. Tag [s162, pipeline-stage-folders, 4-folder-gd, permission-policy-key-gate, index-query-key-moi, leaf-byte-verbatim, sha-pair-6-file, review-f1-f8]. - **S162 (2026-07-30) Cây 4 folder GIAI ĐOẠN dưới từng gói thầu — 3 file MỚI + 3 trang, MIRROR đủ 2 app, build PASS ×2.** Owner (screenshot khoanh đỏ dải 4 GĐ → cây trái trang Duyệt NCC) + AskUser chốt: folder đặt DƯỚI TỪNG GÓI THẦU · áp cả trang GĐ khác. Nhãn VERBATIM "Duyệt NCC · Kế hoạch Hợp đồng · Duyệt Hợp đồng · Hợp đồng cứng" (GĐ2 CỐ Ý khác nhãn strip `PePipelineStrip` = lời-mới-nhất-thắng; KHÔNG sửa strip/menu). **3 file mới:** `components/pipeline/PipelineStageFolders.tsx` (thuần trình bày; `<details>` controlled `useState(n===currentStage)`; prop `content` cho GĐ1 giữ leaf phiếu cũ; `locked`/`failed`/`loading`/`hideEmpty`) · `hooks/usePipelineStages.ts` (2 query index key MỚI `pipeline-khkk-index`/`pipeline-contract-index` pageSize 200 — `PagedResult.cs:20` KẸP ≤200 không 400; `buildStages(peItems)` nối GĐ2 qua `khkk.purchaseEvaluationId`, GĐ3 qua `pe.contractId`, GĐ4 = HĐ đó có `hasSealedCopy`) · `components/pipeline/PipelineTreePanel.tsx` (panel cây trái cho trang GĐ2/GĐ4; `buildTree` RIÊNG, KHÔNG đụng memo `yearGroups` trang PE). **Wire:** PE list (`currentStage=1`, khối `<li>` phiếu giữ NGUYÊN TỪNG BYTE kể cả thụt lề ⇒ diff chỉ 2 mẩu bọc = bằng chứng không đụng behavior) · KhkkListPage (=2) · HardCopiesPage (=4), 2 trang sau bọc lưới `lg:grid-cols-[19rem_minmax(0,1fr)]` + `min-w-0` + `<table>` bọc `overflow-x-auto` + KPI `lg:grid-cols-6``xl:`. **Permission 2 tầng (S118/#82):** gate TRƯỚC fetch, `enabled:false` ⇒ 0 rác 403 **VỚI ĐIỀU KIỆN gate = ĐÚNG key policy của endpoint** *(reviewer F-1 @S162 bắt, lead sửa cùng lượt: GĐ2 `/contract-signing-plans` = `KeHoachKyKet.Read`, `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa/OR-con ⇒ OR key con sẽ fetch rồi ăn 403 khi role thiếu key root; gate đã đổi về `['KeHoachKyKet']`. GĐ3/4 `/contracts` `[Authorize]` trần nên OR key hiển-thị vô hại)*. ⚠️ menu-tree fallback quyền cha CHỈ khi menu con KHÔNG có row riêng (`GetMyMenuTreeQuery.cs:66`) — seeder TẠO row `Ct_*` (`DbInitializer.cs:2168-2176`) ⇒ `Ct_*` thực tế KHÔNG kế thừa `Contracts` *(câu "Ct_* kế thừa Contracts" bản đầu entry này SAI — reviewer F-8, đã sửa)*. **+5 fix hậu-review cùng lượt (lead áp):** F-1 gate-policy-key · F-3 leaf ngoài-index nhãn "(ngoài chỉ mục 200 — mở để xem)" tách nghĩa "(chưa cấp mã)" · F-4 index giữ `total` ⇒ cờ `truncated` khai cửa-sổ-200 ngay trên cây (footer folder + notice panel) · F-6 `hideEmpty` KHÔNG nuốt folder failed/loading · F-7 upload bản cứng invalidate thêm `pipeline-contract-index`. Giới hạn khai thẳng: F-2 phiếu liên-danh N-HĐ chỉ nối được HĐ[0] qua `pe.contractId` (chờ W5 link KHKK→HĐ) · F-5 lưới 19rem 2 trang chưa render thật (harness chỉ import 2 component) — nghiệm thu prod. **Evidence:** `.claude/workflows/runs/2026-07-30-S162-cay-4-folder-gd/sub-frontend-designer-0.md` + `ship-synthesis.md` · SHIP `bfc7b79` · cicd PASS 5/5 Run #432. 🔸 *H2 F-03 @S162: `ship-synthesis` trích `VERDICT: SHIP-READY` như verbatim của tao nhưng sub-file **0 hit** chuỗi đó (kết bằng "TỰ ĐÁNH GIÁ CÒN HỞ", không END-line) — nội dung khớp đĩa nên KHÔNG phải bịa, nhưng từ nay **mọi `sub-*.md` PHẢI kết bằng `END <slug> — VERDICT=<…>`*. Tag [s162, pipeline-stage-folders, 4-folder-gd, permission-policy-key-gate, index-query-key-moi, leaf-byte-verbatim, sha-pair-6-file, review-f1-f8].
- **S159 (2026-07-29) ARC-2 "Tổng quan quy trình" ×2 lượt — `UserDashboardPage.tsx` fe-user (đợt-1 hero pipeline + đợt-4 redesign list toàn-trình).** *(Entry on-behalf bởi lead @S160 closeout M9 — VERIFY từ 2 sub-file + commit; cả 2 lượt vai ghi-đĩa-trong-lúc-làm nên #53 ×2 first-return mất 0 B.)* **Đợt-1** (`b5799fc`, `sub-frontend-designer-tongquan.md` 15.803 B): file 309 dòng → THÊM hero pipeline 4 GĐ trên cùng, GIỮ trọn 3 query (`my-dashboard`/`my-contracts-recent`/`my-pe-recent`) + 5 StatCard + 2 list (owner: *"đừng vứt"*); đổi PageHeader title "Tổng quan tất cả luồng duyệt". **Đợt-4** (`a2bbcb9`, `sub-frontend-designer-dot4.md` 13.956 B): file lúc đó 766 dòng — đọc đĩa KHÔNG dùng bản nhớ (đã qua 3 đợt sửa nhãn giữa 2 lượt); bỏ khối owner khoanh (5 StatCard + 2 list "gần đây") → **list chi tiết toàn-trình**; GIỮ hero (owner không khoanh — *đợt-5 vai khác mới bỏ hero theo lệnh anh*); nhãn 4 GĐ echo bản MỚI NHẤT từ comment đầu file (GĐ2 "Kế hoạch ký kết HĐ", KHÔNG lấy tên đợt-1 "Đề xuất ký kết hợp đồng" đã superseded); +2 query `['pipeline-pe-approved']` (phase=7 pageSize=1 lấy `.total`) + `['pe-inbox']` **dùng `select: d => d.length` để KHÔNG vỡ cache dùng chung InboxPage**; verify param BE bằng đọc controller thật. 🔴 **Môi trường spawn KHÔNG có dev-server/Playwright ⇒ FD2 visual-loop bất-khả — thay bằng `npm run build` PASS + tự-review tĩnh FD4, KHAI THẲNG không bịa screenshot** (khuôn NO-SCREENSHOT này tái dùng cho mọi spawn thiếu rig). Bài giữ lại: (i) giữa 2 lượt cùng file, dòng-đếm/nhãn đổi ⇒ **khảo sát lại từ đĩa mỗi lượt**; (ii) đổi consumer của endpoint dùng-chung → `select` transform thay đổi shape cache. Tag [s159, tong-quan-quy-trinh, hero-pipeline-4gd, dot4-list-toan-trinh, no-screenshot-khai-that, select-transform-cache-safe, doc-dia-moi-luot, on-behalf-m9]. - **S159 (2026-07-29) ARC-2 "Tổng quan quy trình" ×2 lượt — `UserDashboardPage.tsx` fe-user (đợt-1 hero pipeline + đợt-4 redesign list toàn-trình).** *(Entry on-behalf bởi lead @S160 closeout M9 — VERIFY từ 2 sub-file + commit; cả 2 lượt vai ghi-đĩa-trong-lúc-làm nên #53 ×2 first-return mất 0 B.)* **Đợt-1** (`b5799fc`, `sub-frontend-designer-tongquan.md` 15.803 B): file 309 dòng → THÊM hero pipeline 4 GĐ trên cùng, GIỮ trọn 3 query (`my-dashboard`/`my-contracts-recent`/`my-pe-recent`) + 5 StatCard + 2 list (owner: *"đừng vứt"*); đổi PageHeader title "Tổng quan tất cả luồng duyệt". **Đợt-4** (`a2bbcb9`, `sub-frontend-designer-dot4.md` 13.956 B): file lúc đó 766 dòng — đọc đĩa KHÔNG dùng bản nhớ (đã qua 3 đợt sửa nhãn giữa 2 lượt); bỏ khối owner khoanh (5 StatCard + 2 list "gần đây") → **list chi tiết toàn-trình**; GIỮ hero (owner không khoanh — *đợt-5 vai khác mới bỏ hero theo lệnh anh*); nhãn 4 GĐ echo bản MỚI NHẤT từ comment đầu file (GĐ2 "Kế hoạch ký kết HĐ", KHÔNG lấy tên đợt-1 "Đề xuất ký kết hợp đồng" đã superseded); +2 query `['pipeline-pe-approved']` (phase=7 pageSize=1 lấy `.total`) + `['pe-inbox']` **dùng `select: d => d.length` để KHÔNG vỡ cache dùng chung InboxPage**; verify param BE bằng đọc controller thật. 🔴 **Môi trường spawn KHÔNG có dev-server/Playwright ⇒ FD2 visual-loop bất-khả — thay bằng `npm run build` PASS + tự-review tĩnh FD4, KHAI THẲNG không bịa screenshot** (khuôn NO-SCREENSHOT này tái dùng cho mọi spawn thiếu rig). Bài giữ lại: (i) giữa 2 lượt cùng file, dòng-đếm/nhãn đổi ⇒ **khảo sát lại từ đĩa mỗi lượt**; (ii) đổi consumer của endpoint dùng-chung → `select` transform thay đổi shape cache. Tag [s159, tong-quan-quy-trinh, hero-pipeline-4gd, dot4-list-toan-trinh, no-screenshot-khai-that, select-transform-cache-safe, doc-dia-moi-luot, on-behalf-m9].
- _(**S79 (2026-06-19) PE list fe-user — DECOUPLE chọn/mở-rộng: `?expand=1` cạnh `?id`, nút Maximize2 mỗi dòng, ALL logic verbatim, harness-FD2 6 shot.** verbatim → `archive/2026-06.md` §"S79 (2026-06-19)")_ - _(**S79 (2026-06-19) PE list fe-user — DECOUPLE chọn/mở-rộng: `?expand=1` cạnh `?id`, nút Maximize2 mỗi dòng, ALL logic verbatim, harness-FD2 6 shot.** verbatim → `archive/2026-06.md` §"S79 (2026-06-19)")_

View File

@ -11,9 +11,9 @@
}, },
"_tick_invariant_note": "Tick invariant (hub dede7ec5 Delta-1, verbatim): moi LAN-CHOT +1; mot cap dung-noi tang DUNG +1, khong +2, khong +0. SE form = tick-at-entry-gate idempotent-per-label (hub Delta-2 recovery-gate +1 = permitted form) - NET +1/session-label EQUIV hub +1/cap on CLOSED pairs; an OPEN pair is transiently +0 until its entry gate fires (hub blessed, 9a35405b block-1 phep dung-noi). Guard song-con = label-convention (session-start 2.1.8: new conversation = new S\u003cnn\u003e label, NEVER reuse). history[] is append-unbounded BY DESIGN =\u003e absence of a marker = never-happened (safe semantics); IF a FIFO cap is ever added, eviction MUST be handled explicitly (absence-vi-bi-day != absence-vi-chua-xay-ra - log-BOUNDED design-note dede7ec5).", "_tick_invariant_note": "Tick invariant (hub dede7ec5 Delta-1, verbatim): moi LAN-CHOT +1; mot cap dung-noi tang DUNG +1, khong +2, khong +0. SE form = tick-at-entry-gate idempotent-per-label (hub Delta-2 recovery-gate +1 = permitted form) - NET +1/session-label EQUIV hub +1/cap on CLOSED pairs; an OPEN pair is transiently +0 until its entry gate fires (hub blessed, 9a35405b block-1 phep dung-noi). Guard song-con = label-convention (session-start 2.1.8: new conversation = new S\u003cnn\u003e label, NEVER reuse). history[] is append-unbounded BY DESIGN =\u003e absence of a marker = never-happened (safe semantics); IF a FIFO cap is ever added, eviction MUST be handled explicitly (absence-vi-bi-day != absence-vi-chua-xay-ra - log-BOUNDED design-note dede7ec5).",
"_seed_honesty": "Seeded UNTICKED on purpose. counter=0 and last_ticked_* = null mean \u0027no tick has ever happened\u0027, which is the truth at S121 - the ritual that performs the tick lands in W3. Seeding a fake first tick here would make the very first cadence reading a lie, and H24 exists to catch exactly that kind of invented number.", "_seed_honesty": "Seeded UNTICKED on purpose. counter=0 and last_ticked_* = null mean \u0027no tick has ever happened\u0027, which is the truth at S121 - the ritual that performs the tick lands in W3. Seeding a fake first tick here would make the very first cadence reading a lie, and H24 exists to catch exactly that kind of invented number.",
"counter": 47, "counter": 48,
"last_ticked_session": "S173", "last_ticked_session": "S174",
"last_ticked_head": "1b3bcaa2e26c6c722dd2abde2f359fedf58d5ea7", "last_ticked_head": "7fcf0ab5e9d86b85a36d776783d30d3cc174fb3f",
"last_ticked_at": "2026-08-05", "last_ticked_at": "2026-08-05",
"last_audit": { "last_audit": {
"light_at_counter": 46, "light_at_counter": 46,
@ -242,6 +242,11 @@
"at": "2026-08-05", "at": "2026-08-05",
"session": "S173", "session": "S173",
"event": "squash-benign (session-counter-tick.ps1 M2, contract fail_loud_on_regress trigger-2 BENIGN branch): counter 46->47, session S172->S173, head 37f18e9->1b3bcaa. last_ticked_head 37f18e9 object EXISTS (cat-file=commit) but NOT reachable (merge-base --is-ancestor exit!=0), counter not regressed => a closeout squash lifted the ticked wal:/session commit out of history (expected drift, not tamper). Trace appended, continue, no owner alarm. Written atomically (temp + Move-Item -Force)." "event": "squash-benign (session-counter-tick.ps1 M2, contract fail_loud_on_regress trigger-2 BENIGN branch): counter 46->47, session S172->S173, head 37f18e9->1b3bcaa. last_ticked_head 37f18e9 object EXISTS (cat-file=commit) but NOT reachable (merge-base --is-ancestor exit!=0), counter not regressed => a closeout squash lifted the ticked wal:/session commit out of history (expected drift, not tamper). Trace appended, continue, no owner alarm. Written atomically (temp + Move-Item -Force)."
},
{
"at": "2026-08-05",
"session": "S174",
"event": "CLEAN tick (session-counter-tick.ps1 M2): counter 47->48, session S173->S174, head 1b3bcaa->7fcf0ab. Classify-before-tick: no regression (n=174 >= stored n=173); last_ticked_head reachable (merge-base --is-ancestor exit 0). 4 fields updated, 1 history entry appended, written atomically (temp + Move-Item -Force)."
} }
] ]
} }

View File

@ -82,6 +82,36 @@
{ "machine_block": "python scripts/session_ctx.py machine-block --session 11 --json" } { "machine_block": "python scripts/session_ctx.py machine-block --session 11 --json" }
``` ```
### PAUSE-2 2026-08-05T16:54:23+07:00
> anh: xong thì /pause tạm nhé
**(1) quyết-định đã CHỐT**
- Cửa-sổ này **KHÔNG làm việc governance** — anh ngắt mạch vá-5-điểm sang **UAT UI module KHKK**, giao qua **9 ảnh khoanh đỏ + 6 tin nhắn chốt**. Governance 5 điểm vẫn treo nguyên (xem WAL `[ ]`).
- **5 commit ship + deploy XANH ×3 đợt:** `14a917f` · `3f339c0` · `f86f30d` · `d0453ba` · `a1b9cbb`. Prod verify byte-level mỗi đợt (bundle hash ĐỔI **∧** chuỗi mới CÓ — 2 vế, mỗi vế lẻ đều nói dối được).
- **Anh BÁC 1 lần, lead revert sạch:** cột 2-tầng bị lead đặt nhầm vào màn *Duyệt NCC* (đã golive). Anh: *"bỏ phần này đi chỗ duyệt NCC đã golive và đã tốt rồi, ý tao là đang muốn mày làm giống giao diện thao tác của Duyệt NCC"*. `PurchaseEvaluationWorkspacePage` ×2 app trở về **ĐÚNG BYTE** `1b3bcaa` (verify `git show` + control-dương grep 0 hit). Bài ghi vào đầu file component: **"làm giống màn X" ≠ "sửa màn X"**.
- **Ensemble `/fable-clone frontend-designer`** (`wf_016b1bed-b85`): 5 lane cùng 1 vai, **5/5 xong, 0 chết**, 755K tok. Spec 3-mục đã ghi. Luật C2 (ép ghi khung rỗng lượt 1-2) chứng có răng: run trước 3/5 chết, run này 0/5.
- **4 câu owner chốt @đợt-C:** ① O5 giữ máy R-11 sửa hình → OK · ② bỏ chữ SẮP → OK · ③ *"cho màu sắc phân biệt ra từng loại, cùng loại thì cùng màu"* (KHÔNG phải hạ hết về xám — nguyên tắc mạnh hơn cả 2 lựa chọn lead đưa) · ④ bỏ luôn nút Từ chối ~ như PE.
**(2) delta còn SỐNG**
- 🔴 **Đợt B chưa land** (bê style PE): panel duyệt (13 mục) · nội dung phiếu (6 P0 + 8 P1) · panel danh sách (13 delta, có **`slate-400` = 2.56:1** dưới ngưỡng WCAG) · host.
- 🔴 **Đợt D chưa land:** O6 bấm-card-PE-giữ-màn · O3 hạng-mục N-dòng + SUM (**0-migration**, máy rộng sẵn) · O4 shopdrawing (**FE-only**) · O7 nút edit · O8 cây 2 cấp.
- 🔴 **Còn 1 câu chưa hỏi anh:** cách đọc **"sum tổng"** của O3 — đây là **tiền**, lane L5 dặn xác nhận 1 dòng trước khi viết BE.
- 🔴 **NO-SCREENSHOT ×5 lane** (dev-rig chặn: chỉ `:8080` LISTENING, API `:5443` không có, trang sau `ProtectedRoute` — gotcha #3). Mọi kết luận là **đọc mã, không phải đã nhìn** ⇒ người land BẮT BUỘC soi 375/1366/1440 trên prod.
- 5 điểm governance đầu phiên **nguyên vẹn chưa động**: `STATUS:473` 86→87 · gạch 5 slot HANDOFF · `ROTATE vrapp` · re-stamp Carry 14 slug · slot (70) anchor.
- `thu-moi: se=0 all=6` (4× `upgrade-pack-phased` ĐÃ ở `inbox/ai_infra/` chỉ thiếu adap-report · 2× `loi-do-luong` **0 hit toàn đĩa** = chưa kéo thật).
- `ring2-audit/MEMORY.md` vẫn **26.440 B > 25.600** — A1 chưa thi hành (treo từ PAUSE-1).
**(3) con-trỏ**
- run-folder: `.claude/workflows/runs/2026-08-05-S174-khkk-style-audit/` (`run.md` + 5 sub 34-41 KB + `spec-khkk-style-dong-bo-05-08-2026.md`)
- prod hiện tại: admin `index-BXwdUVmD.js` · eoffice `index-DdcCH2oZ.js` (đợt A **chưa** deploy — vừa push `a1b9cbb`, chưa poll)
- 🔴 **Cổng C2 @PAUSE-2 KÊU**`AB = {frontend-designer}` (có 5 artifact, 0 entry S174); lead seed on-behalf 1 entry → `AB = ∅`, sổ 16.373 → **19.869 B** (dưới trần). Control-dương: nhãn S173 có ở **10** sổ ⇒ phép grep sống, không phải glob hỏng.
- 🔴 **Lane BÁC 2 giả-định của lead, lead tự verify thì lane ĐÚNG** — O3 **không cần migration** (`ContractSigningPlanLineConfiguration.cs:37-38` unique **3 cột**) · O4 **FE-only**. Lead đã báo owner sai chiều "O3 nặng nhất cần migration", đã đính chính tại chỗ.
```json
{ "machine_block": "python scripts/session_ctx.py machine-block --session 11 --json" }
```
--- ---
## (c) STOCK-touched — máy-derive ## (c) STOCK-touched — máy-derive
@ -110,3 +140,16 @@
| `…/harness-refine-return.md` | A | 40.048 B — `1 action / 15 bác` | | `…/harness-refine-return.md` | A | 40.048 B — `1 action / 15 bác` |
| `…/harness-audit-return.md` | A | 57.039 B — `50Đ/7T` | | `…/harness-audit-return.md` | A | 57.039 B — `50Đ/7T` |
| _(ngoài bảng máy — chưa commit lúc đo)_ | M×9 | 9 diary lead seed on-behalf @cổng C2 + `ring2-audit` **vượt trần 840 B** | | _(ngoài bảng máy — chưa commit lúc đo)_ | M×9 | 9 diary lead seed on-behalf @cổng C2 + `ring2-audit` **vượt trần 840 B** |
> **Máy-derive @PAUSE-2** — 🔴 `machine-block --session 11` trả `changed_count: 0` vì **anchor CHÍNH LÀ commit vừa tạo** (`a1b9cbb`) nên `git diff anchor..HEAD` rỗng. Đó là **giới-hạn của phép đo, KHÔNG phải "không đụng gì"** — đọc thẳng con số đó thành "cửa-sổ này 0 file" là sai. Lead tự đo khoảng đúng của cửa-sổ-2: `git diff --name-only 7fcf0ab..a1b9cbb` (mốc `_tiep-1` → nay) = **28 file**.
| Nhóm | n | Ghi-chú |
|---|---|---|
| `fe-admin/src/**` | 9 | khkk ×4 · pipeline ×2 · pages/khkk ×3 |
| `fe-user/src/**` | 10 | mirror 9 file trên + `pages/InboxPage.tsx` (gỡ cây khỏi Hộp thư — chỉ fe-user có trang này) |
| `runs/2026-08-05-S174-khkk-style-audit/**` | 7 | `run.md` + 5 `sub-frontend-designer-L*.md` + `spec-*.md` |
| `sessions/session-11/_tiep-1.md` | 1 | biên-nhận cửa vào (cân sổ ORPHAN-L) |
| _(loại khỏi bảng)_ | 2 | `WAL.md` · `.session-counter.json` — nhiễu mỗi turn, không mang tin |
> 🔴 **5 commit KHÔNG phải `wal:` trong cửa-sổ này** (đây mới là tín-hiệu "có người làm thật", hook không bao giờ sinh được loại này):
> `14a917f` · `3f339c0` · `f86f30d` · `d0453ba` · `a1b9cbb`.

View File

@ -39,6 +39,62 @@ Máy chỉ quét phần **DƯỚI** marker đóng khối luật. Khối luật
(`_MIND_TOP_MARKER` trong scripts/session_ctx.py) -- khac khuon `FLOW-START` cua _context (khong may nao doc). --> (`_MIND_TOP_MARKER` trong scripts/session_ctx.py) -- khac khuon `FLOW-START` cua _context (khong may nao doc). -->
<!-- MIND-TOP --> <!-- MIND-TOP -->
## MIND-3 — 2026-08-05T16:54:23+07:00 @ a1b9cbb (window 2, @PAUSE-2)
### A. Gói-turn
- Anh ngắt mạch governance sang UAT UI KHKK, giao bằng **9 ảnh khoanh đỏ + 6 tin nhắn chốt** rải suốt cửa-sổ — không phải một đề bài đóng gói sẵn. Số commit / đợt deploy / verdict: xem `_context` PAUSE-2 mục (1).
- 1 lần anh BÁC chỗ đặt, lead revert về đúng byte. Ensemble `/fable-clone frontend-designer` chạy xong; spec 3-mục đã ghi — con-trỏ ở PAUSE-2 mục (3).
- `ctx-curator` chấm nháp block này: **`SUA — 9 điểm`**, lead sửa đủ 9 rồi mới chèn.
### B. Hướng-tiếp + nhánh-đã-loại
- **Đi tiếp (cửa sau):** poll deploy `a1b9cbb` → đợt B (bê style PE) → đợt D (O6 · O3 · O4 · O7 · O8) → **thăng luật E4 vế ➊ lên khối auto-inject****ghi synthesis cho run-folder S173** (đang ORPHAN) → rồi mới quay lại 5 điểm governance.
- 🔴 **Phải hỏi anh 1 câu TRƯỚC khi viết BE cho O3:** cách đọc "sum tổng" — đây là tiền, lane L5 dặn xác nhận.
- **Nhánh đã LOẠI ⑧:** *hạ khuôn Section của KHKK xuống cho bằng PE* — LOẠI: KHKK **giàu hơn** PE (icon-chip/accent/actions). Hướng đúng là **nâng PE lên**, không hạ KHKK.
- **Nhánh đã LOẠI ⑨:** *bê chip NS PRO/NS CCM sang KHKK cho đủ bộ* — LOẠI: 2 cờ đó mở quyền sửa ngân sách per-gói-thầu, khái niệm KHKK không có ⇒ bê sang là vẽ nhãn quyền không tồn tại.
- **Nhánh đã LOẠI ⑩:** *thay cả 3 chỗ SoonChip bằng số 0* — LOẠI: ca "chưa có QUYỀN xem" mà in 0 là nói dối về dữ-liệu. Ba ca ba cách: đếm-được → in số · khoá-quyền → bỏ trắng · failed → giữ dấu `—`.
### C. Kế-hoạch (delta suy-nghĩ)
- **Đổi so MIND-2:** lead bị vai kiểm bác **2 giả-định** và cả 2 lần lane đúng. ⇒ nhận: **trước khi tuyên một việc là "nặng", phải đọc EF configuration + index, đừng suy từ hình-dạng UI**. Chính khuôn `máy rộng, UI hẹp` anh chốt S164 — schema tổng-quát sẵn từ lâu, chỉ UI khoá 1-1.
- **Đổi thứ hai — sửa overclaim của chính bản nháp đầu:** cửa-sổ này lặp đúng MỘT họ lỗi *"trông như đã làm nhưng không làm gì"* (grep sai hình · thiếu `cd` build nhầm app · `git add` abort sau `git mv` · `border-0` bị `.card-accent` nuốt · `JSON.parse` free-text). Nháp đầu gọi đây là "luật tự rút" — **SAI**: luật `0-hit phải có control-dương` **đã tồn tại từ S172** trong thân `harness-eval/MEMORY.md`. Delta thật = **bằng-chứng 5 ca cùng họ trong một cửa-sổ**, và việc phải làm là **THĂNG luật lên khối auto-inject** (nó đang nằm dưới vùng planner mù nên không tới tay ai), KHÔNG phải rút lại lần 2.
- **Đổi thứ ba:** anh trả lời câu-3 hay hơn cả hai lựa chọn lead đưa ra — lead hỏi nhị-phân *giữ màu hay bỏ màu*, anh chốt một **nguyên-tắc** phân màu theo LOẠI (nguyên văn: xem PAUSE-2 mục (1)④). ⇒ khi hỏi owner, **đừng gói câu hỏi thành nhị-phân** nếu trục thật là một nguyên-tắc.
### D. Đang-thảo-luận
- Cách đọc "sum tổng" của O3 — là tiền, phải hỏi trước khi viết BE {treo-chờ-anh}
- Đợt B — HƯỚNG bê style PE đã được anh chốt {gần-chốt}
- Đợt B — NỘI DUNG từng mục lane đề xuất, anh chưa xem mục nào {mới-nêu}
- `slate-400` = 2.56:1 dưới ngưỡng WCAG cho 6 dòng chữ mang thông tin {mới-nêu}
- O6 bấm card PE giữ nguyên màn KHKK — callback-hoá `onOpenPe` mirror `onOpenKhkk` {gần-chốt}
- O7 nút edit trên card + O8 cây thư mục 2 cấp — tới sau khi lane đã phóng, lane không thấy {mới-nêu}
- NO-SCREENSHOT ×5 lane ⇒ mọi kết luận là đọc mã; phải soi thật 3 khổ màn trên prod {mới-nêu}
- Nâng PE lên khuôn KHKK — run riêng có ảnh đối chứng, PE đang golive {treo-chờ-anh}
- E4 vế ➊ chưa land — thăng luật lên khối auto-inject, chủ = LEAD {gần-chốt}
- Run-folder S173 ORPHAN (0 `*-synthesis.md`) — Sàn-3 ① sẽ kêu lần 2 nếu bỏ {gần-chốt}
- 2 thư `loi-do-luong` chưa kéo thật {mới-nêu}
- `ring2-audit/MEMORY.md` vượt `autoinject_cap` — A1 có payload, treo từ PAUSE-1 {gần-chốt}
- Nâng `autoinject_cap` thay vì archive — trần là quyền anh {treo-chờ-anh}
- Bản thân việc ROTATE mật-khẩu prod {treo-chờ-anh}
- Slot (67) người soạn có được upload file khi duyệt {treo-chờ-anh}
- Nợ harvest thật vs con số HANDOFF ghi — gom hay để ngủ {treo-chờ-anh}
-`STATUS:473` Gotchas 86→87 — việc lead, đã quyết chưa thi hành {gần-chốt}
- Gạch 5 slot (68)-(72) khỏi HANDOFF — đã ratify `6730eb4`, sổ chưa lật {gần-chốt}
- Đưa `ROTATE vrapp` trở lại HANDOFF — việc lead {gần-chốt}
- Re-stamp Carry 14 slug — bỏ tiếp thành streak 12 {gần-chốt}
- Slot (70) vá anchor `^\*\*` neo cả `**`/`##`/`###` {gần-chốt}
- 9 dir rỗng `agent-memory/<role>/` trong run-folder — dọn @session-end {mới-nêu}
- Thước MFE thiên lệch theo độ dài diary, ngược trục `autoinject_cap` {mới-nêu}
- Headroom: cắt `STATUS.md` + `HANDOFF.md` theo tiering hay nâng trần {mới-nêu}
- Monthly drift-audit quá hạn {mới-nêu}
### E. Dòng-sống spawn/engine-run
- `ctx-verifier` @`/tiep`**`NAP-LAI-SAN-SANG — 10 cờ`**; 2 cờ sửa thẳng reconcile của lead (kênh thư nhầm ledger · "kéo 6" gộp 2 trạng-thái) — con-trỏ `_context` PAUSE-2 mục (3)
- `/fable-clone frontend-designer` ensemble — lần phóng ĐẦU **`wf_edc388bf-f8f` CHẾT** ở `JSON.parse(args)` (free-text ⟂ object; 0 agent · 0 token · 20ms); lần hai **`wf_016b1bed-b85`** 5 lane / 5 xong / 0 chết. Verdict từng lane + artifact: `.claude/workflows/runs/2026-08-05-S174-khkk-style-audit/run.md` §Synthesis
- `ctx-curator` @PAUSE-2 — **`SUA — 9 điểm`**, verdict ghi vào `_context` entry `PAUSE-2` mục (3)
## MIND-2 — 2026-08-05T12:13:25+07:00 @ 037fe63 (window 1, @PAUSE-1) ## MIND-2 — 2026-08-05T12:13:25+07:00 @ 037fe63 (window 1, @PAUSE-1)
### A. Gói-turn ### A. Gói-turn

View File

@ -0,0 +1,5 @@
ts: 2026-08-05T16:54:23+07:00
head-sha: a1b9cbb17521f2d87d8b47c94e9b72cb37a95075
window-ordinal: 2
jsonl-hint: D--Dropbox-CONG-VIEC-SOLUTION-SOLUTION-ERP/e22c19fa-68f7-4269-87ab-24465b75de9d
account-label: none

View File

@ -0,0 +1,6 @@
ts: 2026-08-05T17:06:33+07:00
head-sha: 213eec07ed257dd1a5f24229e516937464d4cb33
window-ordinal: 3
jsonl-hint: D--Dropbox-CONG-VIEC-SOLUTION-SOLUTION-ERP/041d0937-4486-46cb-ab80-a2a988516b25
account-label: none
ctx-verifier: NAP-LAI-SAN-SANG — 13 co (2 co nang tu-do-lai CONFIRMED: run.md Owner-items O2/O3 stale -> lead da flush; 2/3 con-tro MIND-3 muc E chet tang noi-dung)

View File

@ -0,0 +1,51 @@
# audit-synthesis — KHKK style audit vs Duyệt NCC (S174)
> **Dạng-1 phẳng** (`<stage>-synthesis.md`) theo `workflows/README.md:17` — RAW `sub-*` ⟂ **VERIFIED** `-synthesis.md`.
> Dạng-3 (chỉ có heading `## Synthesis` trong `run.md`) **đã retire @S123** ⇒ không ghi file này thì Sàn-3 ① kêu ORPHAN.
> Engine **propose-only**; file này = **bản lead ĐÃ VERIFY**, không phải bê thẳng lane.
## Nguồn
`/fable-clone frontend-designer` — ensemble 5 lane CÙNG 1 VAI (đa-lăng-kính), run `wf_016b1bed-b85`.
**5/5 xong · 0 chết · 0 rỗng** · 755.738 tok · 147 tool-use · ~17.
🧊 Lần phóng đầu `wf_edc388bf-f8f` **chết ở `JSON.parse(args)`** (lead truyền free-text, script đòi object JSON) — **0 agent · 0 token · 20ms**; guard nằm ở dòng đầu nên hỏng sạch, không đốt gì.
## Verdict từng lane
| lane | verdict (END-line của chính sub-file) |
|---|---|
| **L1** panel-duyệt | 15 lệch có-thể-sửa (13 bê-thẳng + 1 bán-bê kèm vá 401 + 1 chờ-owner) · 1 KHÔNG-BÊ vì thiếu khái niệm · H2+H4 đã khớp · lệch nặng nhất = **vỏ `card-accent`**, owner KHÔNG nêu |
| **L2** panel-list | **CHƯA-PASS** — 13 delta + 3 trục rubric đỏ · O5 = **GIỮ-MÁY-R11-SỬA-HÌNH** |
| **L3** host | **KHỚP-MỘT-PHẦN** — 2 lệch nặng (**grid 300→260** · **`<lg` ngõ cụt không tạo được phiếu**) · 3 lệch vừa · 3 mục CHỐT-KHÔNG-SỬA |
| **L4** cây | **SHIP-SPEC-READY** — O2 + O6 đều thi-hành-được, 6 host đã liệt kê ảnh hưởng |
| **L5** nội-dung | **AUDIT-DONE** — O3 = **N-ROW 0-migration** · O4 = **FE-ONLY 0-BE** · 6 P0 + 8 P1 + 3 P2 |
## Lead refute — 2 claim nặng nhất, tự đo lại
| claim của lane | lead đo lại | kết |
|---|---|---|
| O3 hạng-mục N-dòng **không cần migration** | `ContractSigningPlanLineConfiguration.cs:37-38` unique index **3 CỘT** `(PlanId, SupplierId, CatalogEntryId)` | ✅ lane ĐÚNG — lead từng báo owner "nặng nhất, cần migration", **SAI, đã đính chính** |
| KHKK tải file **401** | KHKK `:501` `<a href>` trần ⟂ PE `:331` `responseType:'blob'`; JWT chỉ gắn qua interceptor axios | ✅ lane ĐÚNG — **lỗi thật**, không phải style |
## 3 phát hiện owner KHÔNG nêu mà nặng hơn phần lớn danh sách owner nêu
1. **X1** — nút tải file KHKK **401, không chạy**.
2. **X2** — vỏ `card-accent` bọc panel KHKK ⟂ PE panel trần ⇒ thứ làm 2 bên "nhìn khác hệ" **trước** mọi mục owner kê.
3. **X3** — màn Thao tác `<lg`: `<main hidden lg:block>`**mobile không tạo được phiếu**. 🔴 **Regression do chính lead gây ra cùng phiên.**
## Đã LAND ngay (commit `a1b9cbb`)
Đợt A (3 lỗi thật) + 2 câu owner chốt: vá 401 · vá `<lg` ngõ cụt · trả nút duyệt mobile · gỡ `SoonChip` (**3 ca 3 cách**: đếm-được → in số · khoá-quyền → bỏ trắng · failed → giữ `—`) · gỡ nút Từ chối (chỉ gỡ đường bấm, **máy BE giữ nguyên**) · grid 260/320.
## Còn nợ
Đợt B (32 mục style) · đợt D (O6 · O3 · O4 · O7 · O8). Spec đầy đủ: `spec-khkk-style-dong-bo-05-08-2026.md`.
🔴 **KHÔNG lane nào có screenshot** — dev-rig chặn (chỉ `:8080` LISTENING · API `:5443` không có · trang sau `ProtectedRoute`, gotcha #3). Mọi kết luận là **đọc mã, KHÔNG phải đã nhìn**. Người land BẮT BUỘC soi **375 / 1366 / 1440** trên **prod sau deploy** trước khi tuyên PASS.
Thay cho mắt, lane dùng: đo class trên đĩa · `sha256sum` mirror · **tự tính tương-phản WCAG** — bắt được `slate-400` = **2.56:1** cho 6 dòng chữ mang thông tin.
## Bài giữ lại
🧪 **Luật C2 (ép ghi khung rỗng lượt 1-2 TRƯỚC khi đọc) có RĂNG THẬT** — run trước cùng khuôn **3/5 lane chết** cháy-lượt; run này **0/5**. Cùng model, cùng harness, khác **thứ tự ghi**.
END audit-synthesis — VERDICT=SPEC-READY · đợt A ĐÃ LAND (`a1b9cbb`) · đợt B+D còn nợ · 0 screenshot (khai thẳng)

View File

@ -20,8 +20,8 @@ mỗi lệch phải có `file:line` CẢ HAI phía; đầu ra = spec thi-hành-
| # | Việc | Trạng thái | | # | Việc | Trạng thái |
|---|---|---| |---|---|---|
| O1 | `KhkkWorkflowPanel` style ≠ `PeWorkflowPanel` (banner · icon tròn · chip NS PRO/CCM · HÀNH ĐỘNG · file đính kèm · lịch sử duyệt) | chưa làm | | O1 | `KhkkWorkflowPanel` style ≠ `PeWorkflowPanel` (banner · icon tròn · chip NS PRO/CCM · HÀNH ĐỘNG · file đính kèm · lịch sử duyệt) | chưa làm |
| O2 | Badge **"SẮP"** trên cây → **đổi thành 0** ("bỏ hết đi đổi thành 0") | chưa làm | | O2 | Badge **"SẮP"** trên cây → **đổi thành 0** ("bỏ hết đi đổi thành 0") | **LAND `a1b9cbb`** — gỡ `SoonChip` 3 ca 3 cách (đếm-được→số · khoá-quyền→trắng · failed→`—`); deploy verify 2-vế @S175 |
| O3 | Hạng mục cho chọn **NHIỀU** + **SUM tổng**; tổng giá duyệt ghi **PHÍA TRÊN**🔴 đổi cardinality 1→N, cần BE+migration | chưa làm | | O3 | Hạng mục cho chọn **NHIỀU** + **SUM tổng**; tổng giá duyệt ghi **PHÍA TRÊN****N-ROW 0-MIGRATION** (`ContractSigningPlanLineConfiguration.cs:37-38` unique **3 cột**, máy rộng sẵn từ Mig 71) | chưa làm |
| O4 | Shopdrawing / căn cứ hồ sơ cho **đính kèm file** | chưa làm | | O4 | Shopdrawing / căn cứ hồ sơ cho **đính kèm file** | chưa làm |
| O5 | Menu Đang duyệt / Đã duyệt / Đã xóa "lọc đúng trạng thái" — lead đã truy 5/5 mắt xích, **chuỗi lọc ĐÚNG**; nghi vấn thật = `KhkkListPanel:202 flatMode` đổi CÂY→PHẲNG khi có filter | cần phán | | O5 | Menu Đang duyệt / Đã duyệt / Đã xóa "lọc đúng trạng thái" — lead đã truy 5/5 mắt xích, **chuỗi lọc ĐÚNG**; nghi vấn thật = `KhkkListPanel:202 flatMode` đổi CÂY→PHẲNG khi có filter | cần phán |
| O6 | **Bấm card PE trong cây ở màn KHKK phải GIỮ NGUYÊN màn KHKK** (view tại chỗ), không văng sang phiếu gốc — "tương tự với duyệt NCC" | chưa làm | | O6 | **Bấm card PE trong cây ở màn KHKK phải GIỮ NGUYÊN màn KHKK** (view tại chỗ), không văng sang phiếu gốc — "tương tự với duyệt NCC" | chưa làm |
@ -52,10 +52,27 @@ gỡ cây khỏi Hộp thư. Prod hiện tại: admin `index-BXwdUVmD.js` · eof
## Stages ## Stages
- [ ] L1..L5 chạy song song (hmw parallel) - [x] L1..L5 chạy song song (hmw parallel)`wf_016b1bed-b85`, 5/5 xong · 0 chết · 0 rỗng · 755K tok · 147 tool-use · ~17
- [ ] lead refute + synthesize (P3/P4) - [x] lead refute + synthesize (P3/P4) — tự verify 2 claim nặng nhất, cả 2 lane ĐÚNG (dưới)
- [ ] lead ghi `spec-khkk-style-dong-bo-05-08-2026.md` — 3 heading: ① đề-bài ② cách implement ③ checklist acceptance ĐO ĐƯỢC - [x] lead ghi `spec-khkk-style-dong-bo-05-08-2026.md` — 3 heading đủ
## Synthesis ## Synthesis
_(lead điền sau khi verify — engine propose-only, KHÔNG tự ghi)_ **VERDICT từng lane** (dòng-1 lấy từ END-line của chính sub-file):
| lane | verdict |
|---|---|
| L1 panel-duyệt | 15 lệch có-thể-sửa (13 bê-thẳng + 1 bán-bê kèm vá lỗi 401 + 1 chờ-owner) · 1 KHÔNG-BÊ vì thiếu khái niệm (NS PRO/CCM) · H2+H4 đã khớp · **lệch nặng nhất = vỏ `card-accent`, owner KHÔNG nêu** |
| L2 panel-list | **CHƯA-PASS** — 13 delta + 3 trục rubric đỏ · O5 = **GIỮ-MÁY-R11-SỬA-HÌNH** (bỏ `term` khỏi `flatMode` + gộp 2 ô tìm + công-tắc Cây/Danh-sách + chip lọc) |
| L3 host | **KHỚP-MỘT-PHẦN** — 4 trục khớp · **2 lệch nặng: grid 300→260 · `<lg` ngõ cụt không tạo được phiếu** · 3 lệch vừa · 3 mục CHỐT-KHÔNG-SỬA |
| L4 cây | **SHIP-SPEC-READY** — O2: **không GĐ nào còn đúng nghĩa "sắp"**, `SoonChip` không export, 0 host phụ thuộc · O6: đường điều hướng = `usePipelineStages.ts:219` |
| L5 nội-dung | **AUDIT-DONE** — O3 = **N-ROW 0-migration** (máy rộng sẵn từ Mig 71) · O4 = **FE-ONLY 0-BE** (rail `dossierItemId` đủ 6 tầng từ Mig 69) · 6 P0 + 8 P1 + 3 P2 |
**LEAD REFUTE — 2 claim nặng nhất tự đo lại, lane ĐÚNG cả hai:**
- `ContractSigningPlanLineConfiguration.cs:37-38` unique index **3 CỘT** `(PlanId, SupplierId, CatalogEntryId)` ⇒ O3 **0-migration**. Lead từng báo owner "nặng nhất, cần migration" — **SAI, đã đính chính**.
- KHKK `:501` `<a href>` trần ⟂ PE `:331` `responseType:'blob'`**401 xác nhận**, không phải lệch style.
**ĐÃ LAND ngay trong phiên (đợt A + 2 câu owner chốt)** — commit `a1b9cbb`: vá 401 · vá `<lg` ngõ cụt · trả nút duyệt mobile · gỡ `SoonChip` (3 ca 3 cách) · gỡ nút Từ chối · grid 260/320.
🔴 **CÒN NỢ:** đợt B (32 mục style) + đợt D (O6·O3·O4·O7·O8). **KHÔNG lane nào có screenshot** — dev-rig chặn (chỉ `:8080` LISTENING, API `:5443` không có, trang sau `ProtectedRoute`, gotcha #3) ⇒ mọi kết luận là **đọc mã, không phải đã nhìn**; người land BẮT BUỘC soi 375/1366/1440 trên prod.
🧪 **Luật C2 có răng:** run trước cùng khuôn 3/5 lane chết cháy-lượt; run này **0/5**. Cùng model, cùng harness, khác **thứ tự ghi**.

View File

@ -21,7 +21,7 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip' import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
@ -48,6 +48,21 @@ function formatVnd(n: number | null | undefined): string {
return n.toLocaleString('vi-VN') + ' đ' return n.toLocaleString('vi-VN') + ' đ'
} }
// [S175 · B2 · P0] Ô RỖNG phải MỜ HƠN dữ-liệu thật. Trước đây em-dash thừa-hưởng đúng
// màu chữ của ô (`text-slate-600/700`) ⇒ lướt bảng thì "—" trông y như một GIÁ-TRỊ.
// Gom về một chỗ để các ô rỗng không mỗi nơi một kiểu.
const DASH = <span className="text-slate-300"></span>
/** Chuỗi có thể rỗng → em-dash mờ. */
function orDash(v: string | null | undefined): ReactNode {
return v == null || v === '' ? DASH : v
}
/** Tiền: `formatVnd` trả CHUỖI "—" khi rỗng nên không tô mờ được — tách nhánh rỗng ra. */
function moneyOrDash(n: number | null | undefined): ReactNode {
return n == null ? DASH : formatVnd(n)
}
function formatDateTime(iso: string): string { function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN') return new Date(iso).toLocaleString('vi-VN')
} }
@ -362,17 +377,32 @@ export function KhkkDetailContent({
chipFg="var(--color-teal-700)" chipFg="var(--color-teal-700)"
actions={ actions={
plan.budgetFrozen ? ( plan.budgetFrozen ? (
<span className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800"> // [S175 · B2 · P0] emoji 🔒 → icon lucide: emoji render khác nhau theo font hệ
🔒 Giá đã chốt tại thời điểm duyệt // điều-hành (Windows/macOS/Android ra 3 hình), và nó KHÔNG ăn `currentColor`
// nên luôn lệch tông với chữ amber bên cạnh.
<span className="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
<Lock className="h-3 w-3" aria-hidden /> Giá đã chốt tại thời điểm duyệt
</span> </span>
) : undefined ) : undefined
} }
> >
{plan.lines.length === 0 ? ( {plan.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa NCC trúng thầu.</div> <div className="text-sm italic text-slate-400">Phiếu nguồn chưa NCC trúng thầu.</div>
) : ( ) : (
// [S175 · B2 · P0] `table-fixed` + `<colgroup>` + `min-w-[880px]`: bảng co giãn tự-do
// trước đây để cột tiền nhảy bề rộng theo độ dài tên NCC ⇒ 3 cột số không thẳng hàng
// giữa các phiếu. Chốt bề rộng rồi thì hẹp màn hình sẽ CUỘN NGANG (đúng ý đồ của
// `overflow-x-auto` bọc ngoài) thay vì bóp chữ.
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
<col className="w-[22%]" />
<col className="w-[22%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
</colgroup>
<thead className="border-b border-slate-200 bg-slate-50/70"> <thead className="border-b border-slate-200 bg-slate-50/70">
<tr> <tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th> <th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
@ -385,16 +415,16 @@ export function KhkkDetailContent({
</thead> </thead>
<tbody> <tbody>
{plan.lines.map((l) => ( {plan.lines.map((l) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0"> <tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
<td className="px-3 py-2 font-medium text-brand-800"> <td className="px-3 py-2 font-medium text-brand-800">
{l.supplierName ?? '—'} {orDash(l.supplierName)}
{l.contractId && ( {l.contractId && (
<a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng"> <a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng">
</a> </a>
)} )}
</td> </td>
<td className="px-3 py-2 text-xs text-slate-700"> <td className="px-3 py-2 text-slate-700">
{isDraft && !l.contractId ? ( {isDraft && !l.contractId ? (
<> <>
<select <select
@ -426,12 +456,12 @@ export function KhkkDetailContent({
</div> </div>
)} )}
</> </>
) : l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>} ) : orDash(l.tenHangMuc)}
</td> </td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td> <td className="px-3 py-2 text-right tabular-nums text-slate-600">{moneyOrDash(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td> <td className="px-3 py-2 text-right tabular-nums text-brand-800">{moneyOrDash(l.proposedAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{formatVnd(l.approvedAmount)}</td> <td className="px-3 py-2 text-right tabular-nums text-emerald-700">{moneyOrDash(l.approvedAmount)}</td>
<td className="px-3 py-2 text-xs text-slate-600">{l.note ?? '—'}</td> <td className="px-3 py-2 text-slate-600">{orDash(l.note)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -462,10 +492,18 @@ export function KhkkDetailContent({
} }
> >
{plan.dossierItems.length === 0 ? ( {plan.dossierItems.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa khai căn cứ nào.</div> <div className="text-sm italic text-slate-400">Chưa khai căn cứ nào.</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
<col className="w-[14%]" />
<col className={isDraft ? 'w-[26%]' : 'w-[30%]'} />
<col className="w-[14%]" />
<col className={isDraft ? 'w-[16%]' : 'w-[20%]'} />
<col className={isDraft ? 'w-[18%]' : 'w-[22%]'} />
{isDraft && <col className="w-[12%]" />}
</colgroup>
<thead className="border-b border-slate-200 bg-slate-50/70"> <thead className="border-b border-slate-200 bg-slate-50/70">
<tr> <tr>
<th className="label-eyebrow px-3 py-2 text-left">Loại</th> <th className="label-eyebrow px-3 py-2 text-left">Loại</th>
@ -478,19 +516,19 @@ export function KhkkDetailContent({
</thead> </thead>
<tbody> <tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => ( {plan.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0"> <tr key={d.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
<td className="px-3 py-2 text-xs text-slate-600">{DOSSIER_KIND_LABELS[d.kind] ?? '—'}</td> <td className="px-3 py-2 text-slate-600">{orDash(DOSSIER_KIND_LABELS[d.kind])}</td>
<td className="px-3 py-2 font-medium text-brand-800">{d.name}</td> <td className="px-3 py-2 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}> <span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}>
{DOSSIER_STATUS_LABELS[d.status]} {DOSSIER_STATUS_LABELS[d.status]}
</span> </span>
</td> </td>
<td className="px-3 py-2 text-xs text-slate-600"> <td className="px-3 py-2 text-slate-600">
{d.tvgsName ?? '—'} {orDash(d.tvgsName)}
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>} {d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
</td> </td>
<td className="px-3 py-2 text-xs text-slate-600">{d.note ?? '—'}</td> <td className="px-3 py-2 text-slate-600">{orDash(d.note)}</td>
{isDraft && ( {isDraft && (
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
@ -544,7 +582,7 @@ export function KhkkDetailContent({
} }
> >
{planAttachments.length === 0 ? ( {planAttachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div> <div className="text-sm italic text-slate-400">Chưa file đính kèm.</div>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{planAttachments.map((a) => ( {planAttachments.map((a) => (

View File

@ -23,7 +23,7 @@
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo // (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo
// "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt. // "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt.
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { FileCheck, Inbox, Search } from 'lucide-react' import { ChevronLeft, ChevronRight, FileCheck, Inbox, ListTree, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
@ -144,8 +144,12 @@ export function KhkkPlanCard({
</div> </div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{plan.maKeHoach ?? '(chưa cấp mã)'}</span> <span className="font-mono">{plan.maKeHoach ?? '(chưa cấp mã)'}</span>
{/* [S175 · B3] Chip nhóm: `text-[9px]` là nấc thứ TƯ của thang chữ (13/11/10/9) —
gom về 3 nấc chuẩn 13/11/10. Và `inline` + cỡ chữ khác dòng chứa ⇒ hộp chip cao
khác chữ bên cạnh nên tụt baseline; `inline-flex items-center leading-none` ghim
nó thẳng hàng thay vì dựa vào baseline của 2 cỡ chữ khác nhau. */}
{plan.approvalGroup != null && ( {plan.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700"> <span className="inline-flex shrink-0 items-center rounded border border-violet-200 bg-violet-50 px-1 py-0.5 text-[10px] font-semibold leading-none text-violet-700">
N{plan.approvalGroup} N{plan.approvalGroup}
</span> </span>
)} )}
@ -227,7 +231,7 @@ export function KhkkListPanel({
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group) const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
if (shown.length === 0) { if (shown.length === 0) {
return ( return (
<p className="px-3 py-2 text-[11px] italic text-slate-400"> <p className="px-3 py-2 text-[11px] italic text-slate-500">
{group === null ? 'Gói này chưa có kế hoạch ký kết.' : `Gói này chưa có phiếu nhóm N${group}.`} {group === null ? 'Gói này chưa có kế hoạch ký kết.' : `Gói này chưa có phiếu nhóm N${group}.`}
</p> </p>
) )
@ -275,23 +279,42 @@ export function KhkkListPanel({
))} ))}
</Select> </Select>
{flatMode && !deletedView && canPe && ( {flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400"> <p className="text-[10px] italic text-slate-500">
Đang lọc danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây). Đang lọc danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây).
</p> </p>
)} )}
{!canPe && ( {!canPe && (
<p className="text-[10px] italic text-slate-400"> <p className="text-[10px] italic text-slate-500">
Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh
sách kế hoạch kết vẫn liệt đy đ dưới. sách kế hoạch kết vẫn liệt đy đ dưới.
</p> </p>
)} )}
</div> </div>
{/* [S175 · B3] ĐẦU CỘT — trước đây chế-độ CÂY đi thẳng vào thân, không tiêu-đề cũng
không số đếm ⇒ cột "cụt đầu": lệch cả với màn Thao tác (`KhkkWorkListPanel` có
`Tier` header) LẪN với chính chế-độ phẳng (có dòng đếm ở chân). Mirror đúng khuôn
`Tier` để 2 màn KHKK cùng một hình. */}
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-brand-100 text-brand-700" aria-hidden>
{flatMode ? <FileCheck className="h-3 w-3" /> : <ListTree className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-wide text-slate-600">
{deletedView ? 'Phiếu đã xóa' : flatMode ? 'Danh sách kế hoạch' : 'Cây toàn trình'}
</span>
{flatMode && !list.isLoading && !list.isError && (
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium tabular-nums text-slate-600">
{total}
</span>
)}
</div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{!flatMode && ( {!flatMode && (
<PipelineTreePanel <PipelineTreePanel
currentStage={2} currentStage={2}
hideHeader hideHeader
hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none" bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }} stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/> />
@ -350,22 +373,28 @@ export function KhkkListPanel({
<FileCheck className="mr-1 inline h-3 w-3" /> <FileCheck className="mr-1 inline h-3 w-3" />
{total} kế hoạch Trang {page} / {totalPages} {total} kế hoạch Trang {page} / {totalPages}
</span> </span>
{/* [S175 · B3] Ký-tự trần `←`/`→` → icon: mũi tên dạng chữ đổi hình theo font hệ
điều-hành và KHÔNG có tên cho trình đọc màn hình. Icon + `aria-label` sửa cả hai. */}
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
type="button" type="button"
onClick={() => onPageChange(Math.max(1, page - 1))} onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1} disabled={page <= 1}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40" aria-label="Trang trước"
title="Trang trước"
className="rounded border border-slate-300 p-1 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
> >
<ChevronLeft className="h-3.5 w-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages} disabled={page >= totalPages}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40" aria-label="Trang sau"
title="Trang sau"
className="rounded border border-slate-300 p-1 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
> >
<ChevronRight className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>

View File

@ -171,7 +171,7 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!candidates.isLoading && !candidates.isError && awaiting.length === 0 && ( {!candidates.isLoading && !candidates.isError && awaiting.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400"> <p className="p-3 text-[11px] italic text-slate-500">
Mọi phiếu đã duyệt đu đã kế hoạch kết. Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p> </p>
)} )}
@ -181,7 +181,7 @@ export function KhkkWorkListPanel({
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500"> <span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{projectName} {projectName}
</span> </span>
<span className="shrink-0 text-[10px] text-slate-400">{items.length}</span> <span className="shrink-0 text-[10px] text-slate-500">{items.length}</span>
</div> </div>
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{items.map((pe) => ( {items.map((pe) => (
@ -241,7 +241,7 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!plans.isLoading && !plans.isError && planItems.length === 0 && ( {!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400"> <p className="p-3 text-[11px] italic text-slate-500">
Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại
menu Kế hoạch kết . menu Kế hoạch kết .
</p> </p>
@ -287,7 +287,7 @@ export function KhkkWorkListPanel({
sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc
đang làm, và chỉ nói khi thật sự có cái bị giấu. */} đang làm, và chỉ nói khi thật sự có cái bị giấu. */}
{plans.data && plans.data.items.length > planItems.length && ( {plans.data && plans.data.items.length > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400"> <p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-500">
Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết . Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết .
</p> </p>
)} )}

View File

@ -33,7 +33,8 @@
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Check, History, Paperclip, Send, Trash2, Undo2, Workflow } from 'lucide-react' import { Download, Eye, History, Paperclip, Send, Trash2 } from 'lucide-react'
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -104,6 +105,8 @@ export function KhkkWorkflowPanel({
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
// [S175 · B1] File đang xem-trước inline (khuôn PE `PeWorkflowPanel.tsx:60`).
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
const phase = plan.phase as KhkkPhaseValue const phase = plan.phase as KhkkPhaseValue
const steps = plan.workflowSteps ?? [] const steps = plan.workflowSteps ?? []
@ -174,15 +177,18 @@ export function KhkkWorkflowPanel({
const approvalFiles: KhkkAttachmentDto[] = const approvalFiles: KhkkAttachmentDto[] =
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) (plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
// [BE-5] Lịch sử thay đổi — chỉ tải khi người dùng mở khối (không tốn 1 request/phiếu). // [BE-5] Lịch sử thay đổi.
const [showChangelog, setShowChangelog] = useState(false) // 🔴 [S175 · B1] Khối này TRƯỚC ĐÂY gập lại và query `enabled: showChangelog` (lazy —
// tiết-kiệm 1 request/phiếu). Đợt này bỏ gập theo khuôn PE ⇒ **BẮT BUỘC bỏ luôn cờ
// `enabled`**: giữ lại thì khối luôn hiện nhưng query KHÔNG BAO GIỜ chạy, và nó hỏng
// dưới dạng "Chưa có thay đổi nào được ghi." — tức trông y hệt phiếu chưa từng sửa,
// KHÔNG phải trông như lỗi. Đổi lấy: +1 request mỗi lần mở phiếu, đúng giá PE đang trả.
const changelogs = useQuery({ const changelogs = useQuery({
queryKey: ['khkk-changelogs', plan.id], queryKey: ['khkk-changelogs', plan.id],
queryFn: async () => queryFn: async () =>
(await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, { (await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, {
params: { take: 200 }, params: { take: 200 },
})).data, })).data,
enabled: showChangelog,
}) })
const transition = useMutation({ const transition = useMutation({
@ -267,41 +273,44 @@ export function KhkkWorkflowPanel({
} }
return ( return (
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}> // [S175 · B1 · X2] Panel TRẦN khuôn PE (`PeWorkflowPanel.tsx:345`). Trước đây bọc
// `card-accent` ⇒ thẻ-trong-thẻ, lệch đập vào mắt trước mọi mục owner liệt kê.
// 🔴 GỠ HẲN CLASS, KHÔNG đè `border-0`/`shadow-none`: `.card-accent` (`index.css:112`)
// nằm NGOÀI `@layer` nên thắng utility Tailwind v4 — đè là vô hiệu (gotcha #66, đã cắn thật).
<div className="space-y-4">
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */} {/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
<header className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-5 py-3"> <div>
<span <h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
aria-hidden
>
<Workflow className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
{plan.workflowCode && ( {plan.workflowCode && (
<span className="font-mono text-[11px] text-slate-500"> <p className="mt-0.5 font-mono text-[11px] text-slate-500">
{plan.workflowCode} {plan.workflowCode}
{plan.workflowVersion != null && ` v${plan.workflowVersion}`} {plan.workflowVersion != null && ` v${String(plan.workflowVersion).padStart(2, '0')}`}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>} {plan.workflowName && <> · <span className="font-sans">{plan.workflowName}</span></>}
</span> </p>
)} )}
</header> </div>
<div className="space-y-4 p-5"> {/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */} {/* [S175 · B1] violet → EMERALD khuôn PE (`:360-377`). PE dành violet RIÊNG cho
{plan.endedByLevelFinalize && ( "⚑ Duyệt thay CEO"; dùng violet cả ở đây là trộn 2 nghĩa vào một màu. */}
<div className="rounded border border-violet-300 bg-violet-50 px-3 py-2 text-[11px] text-violet-800"> {plan.endedByLevelFinalize && (
<div className="font-medium"> <div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
Kết thúc tại {plan.finalizeStepName ?? 'cấp được cấu hình'} {isApproved ? (
{plan.finalizeLevelName ? ` · ${plan.finalizeLevelName}` : ''} <span>
</div> Kế hoạch đã kết thúc tại{' '}
<div className="mt-0.5"> <span className="font-semibold">{plan.finalizeStepName ?? 'cấp duyệt'}</span>
{isApproved {plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} không qua CEO.
? 'Kế hoạch đã duyệt xong ngay tại cấp này — không qua CEO / Ban Giám đốc.' </span>
: 'Cấp này được cấu hình duyệt là KẾT THÚC — nếu người duyệt tích ô kết thúc thì kế hoạch không qua CEO.'} ) : (
</div> <span>
</div> Quy trình rút gọn: duyệt đến{' '}
)} <span className="font-semibold">{plan.finalizeStepName ?? 'cấp này'}</span>
{plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} KẾT THÚC,
không trình CEO. Các cấp trước vẫn duyệt như thường.
</span>
)}
</div>
)}
{/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */} {/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */}
{steps.length > 0 ? ( {steps.length > 0 ? (
@ -317,9 +326,10 @@ export function KhkkWorkflowPanel({
{STATUS_ICON[st]} {STATUS_ICON[st]}
</span> </span>
<span className="text-slate-800">Bước {step.order} {step.name}</span> <span className="text-slate-800">Bước {step.order} {step.name}</span>
{/* Chip PHÒNG của Bước (BE-4 join `Departments`). */} {/* Chip PHÒNG của Bước (BE-4 join `Departments`).
[S175 · B1] viền-xám-nền-trắng → EMERALD đặc khuôn PE (`:406-410`). */}
{step.departmentName && ( {step.departmentName && (
<span className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-600"> <span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName} {step.departmentName}
</span> </span>
)} )}
@ -458,11 +468,11 @@ export function KhkkWorkflowPanel({
}} }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'} title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition', 'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50', blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)} )}
> >
<Check className="h-3 w-3" /> Duyệt Duyệt
</button> </button>
<button <button
type="button" type="button"
@ -470,11 +480,11 @@ export function KhkkWorkflowPanel({
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }} onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'} title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition', 'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50', blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50',
)} )}
> >
<Undo2 className="h-3 w-3" /> Trả lại Trả lại
</button> </button>
{/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối {/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối
~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện ~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện
@ -488,7 +498,8 @@ export function KhkkWorkflowPanel({
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-[11px] font-bold text-slate-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700" title="Xóa phiếu này (phiếu chuyển sang mục &ldquo;Đã xóa&rdquo;)"
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
> >
<Trash2 className="h-3 w-3" /> Xóa phiếu <Trash2 className="h-3 w-3" /> Xóa phiếu
</button> </button>
@ -498,32 +509,52 @@ export function KhkkWorkflowPanel({
)} )}
{/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */} {/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */}
{/* [S175 · B1] +`border-t pt-4` +`<h3 text-sm>` +nút `<Eye>` xem trước.
Route BE ĐÃ CÓ `ContractSigningPlansController.cs:231` (`/view`, cùng hình với
PE `:317`) ⇒ chỉ cần truyền `basePath` cho dialog dùng chung.
🔴 Nhãn "Tải xuống" chuyển vào `title=` chứ KHÔNG bỏ: giữ đúng chuỗi đó trong
bundle để phép verify prod còn control-dương soi được. */}
{approvalFiles.length > 0 && ( {approvalFiles.length > 0 && (
<div> <div className="border-t border-slate-200 pt-4">
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700"> <h3 className="text-sm font-semibold text-slate-900">📎 File đính kèm khi duyệt</h3>
<Paperclip className="h-3.5 w-3.5 text-teal-500" /> <p className="mt-0.5 text-[11px] text-slate-500">File do người duyệt tải lên trong quá trình duyệt.</p>
File đính kèm khi duyệt <div className="mt-2 space-y-1">
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{approvalFiles.length}
</span>
</div>
<ul className="mt-1.5 space-y-1.5">
{approvalFiles.map(a => ( {approvalFiles.map(a => (
<li key={a.id} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]"> <div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<div className="min-w-0"> <Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<div className="truncate font-medium text-slate-700">{a.fileName}</div> <span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>{a.fileName}</span>
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div> <span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
</div> {isPreviewable(a.fileName) && (
<button
type="button"
onClick={() => setPreviewAtt(a)}
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
title="Xem trước"
>
<Eye className="h-3 w-3" />
</button>
)}
<button <button
type="button" type="button"
onClick={() => downloadAttachment(a)} onClick={() => downloadAttachment(a)}
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline" className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50"
title="Tải xuống"
> >
Tải xuống <Download className="h-3 w-3" />
</button> </button>
</li> </div>
))} ))}
</ul> </div>
{previewAtt && (
<AttachmentPreviewDialog
open
basePath="/contract-signing-plans"
evaluationId={plan.id}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
</div> </div>
)} )}
@ -531,61 +562,63 @@ export function KhkkWorkflowPanel({
{/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây {/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây
CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn
còn nguyên ở "Lịch sử thay đổi" bên dưới. */} còn nguyên ở "Lịch sử thay đổi" bên dưới. */}
<div> {/* [S175 · B1] Bỏ pill đếm → `(n)` trong ngoặc · thẻ `bg-slate-50/60 text-[11px]`
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700"> → `bg-white p-3 text-sm` · mốc-giờ ĐẨY PHẢI bằng `justify-between`.
<Check className="h-3.5 w-3.5 text-emerald-500" /> 🔴 Badge 2-ngả → 3-NGẢ: trước đây MỌI thứ không phải `Reject` đều tô emerald
Lịch sử duyệt ⇒ "Trả lại" (amber ở PE) bị vẽ xanh như đã-duyệt — đọc ngược nghĩa.
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500"> `toPhase` đã có sẵn (`khkk.ts:307`) nên phân được TraLai ⟂ TuChoi ⟂ Duyệt. */}
{approvals.length} <div className="border-t border-slate-200 pt-4">
</span> <h3 className="text-sm font-semibold text-slate-900">Lịch sử duyệt ({approvals.length})</h3>
</div>
{approvals.length === 0 ? ( {approvals.length === 0 ? (
<p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p> <p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p>
) : ( ) : (
<ul className="mt-1.5 space-y-1.5"> <ul className="mt-2 space-y-1.5">
{approvals.map(a => ( {approvals.map(a => {
<li key={a.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]"> const isReject = a.toPhase === KhkkPhase.TuChoi
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700"> const isReturn = a.toPhase === KhkkPhase.TraLai
<span className={cn( return (
'rounded px-1.5 py-0.5 text-[9px] font-semibold', <li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
a.decision === KhkkApprovalDecision.Reject ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800', <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-slate-700">
)}> <div className="flex flex-wrap items-center gap-x-1.5">
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'} <span className={cn(
</span> 'rounded px-1.5 py-0.5 text-[10px] font-semibold',
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span> isReject ? 'bg-rose-100 text-rose-800'
<span className="text-slate-400">·</span> : isReturn ? 'bg-amber-100 text-amber-800'
<span className="text-slate-500">{formatDateTime(a.approvedAt)}</span> : 'bg-emerald-100 text-emerald-800',
</div> )}>
<div className="mt-0.5 text-slate-600"> {DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'} </span>
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`} <span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
{a.levelName ? ` (${a.levelName})` : ''} </div>
<span className="text-slate-400"> <span className="text-[11px] text-slate-500">{formatDateTime(a.approvedAt)}</span>
{' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]} </div>
</span> <div className="mt-1 text-[12px] text-slate-600">
</div> {a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.comment?.trim() && ( {a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">{a.comment}</div> {a.levelName ? ` (${a.levelName})` : ''}
)} <span className="text-slate-400">
</li> {' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]}
))} </span>
</div>
{a.comment?.trim() && (
<div className="mt-1 whitespace-pre-wrap text-[12px] text-slate-600">{a.comment}</div>
)}
</li>
)
})}
</ul> </ul>
)} )}
</div> </div>
{/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */} {/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */}
<div> {/* [S175 · B1] BỎ GẬP — PE render thẳng (`PeDetailTabs.tsx:787-794`). Nội-dung
<button đã tải sẵn qua query; giấu sau 1 cú bấm chỉ thêm ma-sát, không tiết-kiệm gì. */}
type="button" <div className="border-t border-slate-200 pt-4">
onClick={() => setShowChangelog(v => !v)} <h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-900">
className="flex w-full items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-brand-700"
>
<History className="h-3.5 w-3.5 text-slate-400" /> <History className="h-3.5 w-3.5 text-slate-400" />
Lịch sử thay đi Lịch sử thay đi
<span className="ml-auto text-[10px] font-normal text-slate-400">{showChangelog ? 'Ẩn' : 'Xem'}</span> </h3>
</button> <div className="mt-2">
{showChangelog && (
<div className="mt-1.5">
{changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>} {changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>}
{changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>} {changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>}
{changelogs.data && changelogs.data.length === 0 && ( {changelogs.data && changelogs.data.length === 0 && (
@ -614,10 +647,8 @@ export function KhkkWorkflowPanel({
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
)}
</div> </div>
</div>
{action !== null && ( {action !== null && (
<Dialog <Dialog
@ -625,7 +656,7 @@ export function KhkkWorkflowPanel({
onClose={() => setAction(null)} onClose={() => setAction(null)}
title={dialogTitle} title={dialogTitle}
footer={<> footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button> <Button variant="outline" onClick={() => setAction(null)}>Hủy</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}> <Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'} {transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button> </Button>
@ -663,22 +694,26 @@ export function KhkkWorkflowPanel({
</label> </label>
</div> </div>
)} )}
{/* [S175 · B1 micro-copy] Nhãn Ô NHẬP theo PE (`PeWorkflowPanel.tsx:869` "Ghi chú
(tùy chọn)"). 🔴 CHỈ đổi tên TRƯỜNG — chuỗi "Ý kiến cấp duyệt" ở dòng trợ-giúp
bên dưới là TÊN THẬT của mục đích đến trong phiếu (Section 5, Mig 26), đổi nó
là trỏ người dùng tới một mục không tồn tại. */}
<Label> <Label>
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'} Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'}
</Label> </Label>
<Textarea <Textarea
value={comment} value={comment}
onChange={e => setComment(e.target.value)} onChange={e => setComment(e.target.value)}
rows={3} rows={3}
maxLength={2000} maxLength={2000}
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'} placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ghi chú của bạn (ghi vào phiếu)...'}
/> />
{/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng {/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng
`POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */} `POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */}
{action !== KhkkTransitionAction.Submit && ( {action !== KhkkTransitionAction.Submit && (
<div className="mt-3"> <div className="mt-3">
<Label className="text-xs">File đính kèm (tuỳ chọn)</Label> <Label className="text-xs">File đính kèm (tùy chọn)</Label>
<div className="mt-1 flex flex-wrap items-center gap-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
@ -714,7 +749,7 @@ export function KhkkWorkflowPanel({
{action === KhkkTransitionAction.Approve && ( {action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">
Ý kiến sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại Ghi chú sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại
thì ghi đè ô ). thì ghi đè ô ).
</p> </p>
)} )}
@ -727,7 +762,7 @@ export function KhkkWorkflowPanel({
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
title="🗑 Xóa kế hoạch ký kết" title="🗑 Xóa kế hoạch ký kết"
footer={<> footer={<>
<Button variant="outline" onClick={() => setConfirmDelete(false)}>Huỷ</Button> <Button variant="outline" onClick={() => setConfirmDelete(false)}>Hủy</Button>
<Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}> <Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}>
{removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'} {removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'}
</Button> </Button>
@ -739,6 +774,6 @@ export function KhkkWorkflowPanel({
</div> </div>
</Dialog> </Dialog>
)} )}
</section> </div>
) )
} }

View File

@ -15,10 +15,15 @@ export function isPreviewable(fileName: string): boolean {
type Props = { type Props = {
open: boolean open: boolean
/** Id của bản-ghi CHỦ (phiếu PE, hoặc kế-hoạch KHKK khi truyền `basePath`). */
evaluationId: string evaluationId: string
attachmentId: string attachmentId: string
fileName: string fileName: string
onClose: () => void onClose: () => void
/** [S175 B1] Prefix route của module chủ. Mặc-định `/purchase-evaluations` ⇒ **6 call-site
* PE hiện có KHÔNG phải sửa dòng nào** (module đã golive — mở trục mới mà bắt sửa hết
* call-site cũ là tự đẻ rủi-ro ở chỗ đang chạy tốt). KHKK truyền `/contract-signing-plans`. */
basePath?: string
} }
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline). /** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
@ -29,6 +34,7 @@ type Props = {
* cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */ * cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */
export function AttachmentPreviewDialog({ export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose, open, evaluationId, attachmentId, fileName, onClose,
basePath = '/purchase-evaluations',
}: Props) { }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null) const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -58,7 +64,7 @@ export function AttachmentPreviewDialog({
setLoading(true) setLoading(true)
setError(null) setError(null)
api.get(`/purchase-evaluations/${evaluationId}/attachments/${attachmentId}/view`, { api.get(`${basePath}/${evaluationId}/attachments/${attachmentId}/view`, {
responseType: 'blob', responseType: 'blob',
}) })
.then(res => { .then(res => {
@ -78,7 +84,7 @@ export function AttachmentPreviewDialog({
if (currentUrl) window.URL.revokeObjectURL(currentUrl) if (currentUrl) window.URL.revokeObjectURL(currentUrl)
setBlobUrl(null) setBlobUrl(null)
} }
}, [open, evaluationId, attachmentId]) }, [open, evaluationId, attachmentId, basePath])
const ready = blobUrl && !loading && !error const ready = blobUrl && !loading && !error

View File

@ -89,6 +89,7 @@ export function PipelineTreePanel({
bodyMaxHClass, bodyMaxHClass,
stage2Options, stage2Options,
hideHeader, hideHeader,
hideSearch,
}: { }: {
currentStage: PipelineStageNo currentStage: PipelineStageNo
className?: string className?: string
@ -107,6 +108,12 @@ export function PipelineTreePanel({
// 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi // 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi
// `<lg`. Vì vậy ẩn thanh ⇒ PHẢI ép thân cây hiện luôn, nếu không mobile mất trắng cây. // `<lg`. Vì vậy ẩn thanh ⇒ PHẢI ép thân cây hiện luôn, nếu không mobile mất trắng cây.
hideHeader?: boolean hideHeader?: boolean
// [S175 · B3] Ẩn ô lọc RIÊNG của cây. CHỈ dùng ở host đã có ô tìm của chính nó ngay trên
// (KHKK GĐ2) — ở đó 2 ô tìm xếp chồng nhau mà nghĩa lại khác: ô ngoài đi SERVER-SIDE và
// gõ vào là chuyển hẳn sang danh-sách phẳng (`flatMode`), nên ô trong cây KHÔNG bao giờ
// dùng tới — người dùng gõ phát nào là cây biến mất phát đó.
// 6 host còn lại KHÔNG truyền ⇒ giữ nguyên ô lọc cây như cũ.
hideSearch?: boolean
}) { }) {
const { canPe, buildStages } = usePipelineStages() const { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -232,16 +239,18 @@ export function PipelineTreePanel({
{/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `<lg` vẫn thấy cây. */} {/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `<lg` vẫn thấy cây. */}
<div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}> <div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
<div className="relative border-b border-slate-200 p-2"> {!hideSearch && (
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" /> <div className="relative border-b border-slate-200 p-2">
<input <Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
value={search} <input
onChange={e => setSearch(e.target.value)} value={search}
placeholder="Lọc dự án / hạng mục / mã phiếu…" onChange={e => setSearch(e.target.value)}
aria-label="Lọc cây toàn trình" placeholder="Lọc dự án / hạng mục / mã phiếu…"
className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30" aria-label="Lọc cây toàn trình"
/> className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
</div> />
</div>
)}
<div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}> <div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}>
{!canPe && ( {!canPe && (

View File

@ -3,8 +3,14 @@
// //
// [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện // [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện
// thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về: // thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về:
// · header 1 dòng + grid `lg:grid-cols-[300px_1fr]`; // · header 1 dòng + grid `lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]` — ĐÚNG BYTE
// · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy; // khuôn PE (`PurchaseEvaluationWorkspacePage.tsx:80`). 🧊 Comment cũ ghi `300px` là số
// của bản nháp đầu, KHÔNG bao giờ khớp code sau khi đợt A land 260/320 (S175 vá).
// · Panel 1 = danh sách (`KhkkWorkListPanel`) — 2 tầng, MỖI tầng có header dính ĐỈNH
// (`sticky top-0`) gom theo dự án. 🧊 Comment cũ ghi "nút hành-động dính đáy" là MÔ TẢ
// CỦA PE, không phải của đây: `grep -c Button KhkkWorkListPanel` = **0** (control-dương
// `PeListPanel` = 8). Bê nguyên câu mô tả từ khuôn nguồn sang là tự khai một thứ chưa
// dựng — S175 vá theo L3 H-5.
// · Panel 2 = EmptyState | form tạo | phiếu đang XEM. // · Panel 2 = EmptyState | form tạo | phiếu đang XEM.
// 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC" // 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC"
// (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*). // (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*).

View File

@ -21,7 +21,7 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users, ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip' import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
@ -48,6 +48,21 @@ function formatVnd(n: number | null | undefined): string {
return n.toLocaleString('vi-VN') + ' đ' return n.toLocaleString('vi-VN') + ' đ'
} }
// [S175 · B2 · P0] Ô RỖNG phải MỜ HƠN dữ-liệu thật. Trước đây em-dash thừa-hưởng đúng
// màu chữ của ô (`text-slate-600/700`) ⇒ lướt bảng thì "—" trông y như một GIÁ-TRỊ.
// Gom về một chỗ để các ô rỗng không mỗi nơi một kiểu.
const DASH = <span className="text-slate-300"></span>
/** Chuỗi có thể rỗng → em-dash mờ. */
function orDash(v: string | null | undefined): ReactNode {
return v == null || v === '' ? DASH : v
}
/** Tiền: `formatVnd` trả CHUỖI "—" khi rỗng nên không tô mờ được — tách nhánh rỗng ra. */
function moneyOrDash(n: number | null | undefined): ReactNode {
return n == null ? DASH : formatVnd(n)
}
function formatDateTime(iso: string): string { function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN') return new Date(iso).toLocaleString('vi-VN')
} }
@ -362,17 +377,32 @@ export function KhkkDetailContent({
chipFg="var(--color-teal-700)" chipFg="var(--color-teal-700)"
actions={ actions={
plan.budgetFrozen ? ( plan.budgetFrozen ? (
<span className="inline-flex items-center rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800"> // [S175 · B2 · P0] emoji 🔒 → icon lucide: emoji render khác nhau theo font hệ
🔒 Giá đã chốt tại thời điểm duyệt // điều-hành (Windows/macOS/Android ra 3 hình), và nó KHÔNG ăn `currentColor`
// nên luôn lệch tông với chữ amber bên cạnh.
<span className="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
<Lock className="h-3 w-3" aria-hidden /> Giá đã chốt tại thời điểm duyệt
</span> </span>
) : undefined ) : undefined
} }
> >
{plan.lines.length === 0 ? ( {plan.lines.length === 0 ? (
<div className="text-sm text-muted-foreground">Phiếu nguồn chưa NCC trúng thầu.</div> <div className="text-sm italic text-slate-400">Phiếu nguồn chưa NCC trúng thầu.</div>
) : ( ) : (
// [S175 · B2 · P0] `table-fixed` + `<colgroup>` + `min-w-[880px]`: bảng co giãn tự-do
// trước đây để cột tiền nhảy bề rộng theo độ dài tên NCC ⇒ 3 cột số không thẳng hàng
// giữa các phiếu. Chốt bề rộng rồi thì hẹp màn hình sẽ CUỘN NGANG (đúng ý đồ của
// `overflow-x-auto` bọc ngoài) thay vì bóp chữ.
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
<col className="w-[22%]" />
<col className="w-[22%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
</colgroup>
<thead className="border-b border-slate-200 bg-slate-50/70"> <thead className="border-b border-slate-200 bg-slate-50/70">
<tr> <tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th> <th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
@ -385,16 +415,16 @@ export function KhkkDetailContent({
</thead> </thead>
<tbody> <tbody>
{plan.lines.map((l) => ( {plan.lines.map((l) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0"> <tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
<td className="px-3 py-2 font-medium text-brand-800"> <td className="px-3 py-2 font-medium text-brand-800">
{l.supplierName ?? '—'} {orDash(l.supplierName)}
{l.contractId && ( {l.contractId && (
<a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng"> <a href={`/contracts/${l.contractId}`} className="ml-2 text-[10px] font-medium text-brand-600 hover:underline" title="Dòng này đã được đưa vào hợp đồng">
</a> </a>
)} )}
</td> </td>
<td className="px-3 py-2 text-xs text-slate-700"> <td className="px-3 py-2 text-slate-700">
{isDraft && !l.contractId ? ( {isDraft && !l.contractId ? (
<> <>
<select <select
@ -426,12 +456,12 @@ export function KhkkDetailContent({
</div> </div>
)} )}
</> </>
) : l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>} ) : orDash(l.tenHangMuc)}
</td> </td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{formatVnd(l.peReferenceAmount)}</td> <td className="px-3 py-2 text-right tabular-nums text-slate-600">{moneyOrDash(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{formatVnd(l.proposedAmount)}</td> <td className="px-3 py-2 text-right tabular-nums text-brand-800">{moneyOrDash(l.proposedAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{formatVnd(l.approvedAmount)}</td> <td className="px-3 py-2 text-right tabular-nums text-emerald-700">{moneyOrDash(l.approvedAmount)}</td>
<td className="px-3 py-2 text-xs text-slate-600">{l.note ?? '—'}</td> <td className="px-3 py-2 text-slate-600">{orDash(l.note)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -462,10 +492,18 @@ export function KhkkDetailContent({
} }
> >
{plan.dossierItems.length === 0 ? ( {plan.dossierItems.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa khai căn cứ nào.</div> <div className="text-sm italic text-slate-400">Chưa khai căn cứ nào.</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
<col className="w-[14%]" />
<col className={isDraft ? 'w-[26%]' : 'w-[30%]'} />
<col className="w-[14%]" />
<col className={isDraft ? 'w-[16%]' : 'w-[20%]'} />
<col className={isDraft ? 'w-[18%]' : 'w-[22%]'} />
{isDraft && <col className="w-[12%]" />}
</colgroup>
<thead className="border-b border-slate-200 bg-slate-50/70"> <thead className="border-b border-slate-200 bg-slate-50/70">
<tr> <tr>
<th className="label-eyebrow px-3 py-2 text-left">Loại</th> <th className="label-eyebrow px-3 py-2 text-left">Loại</th>
@ -478,19 +516,19 @@ export function KhkkDetailContent({
</thead> </thead>
<tbody> <tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => ( {plan.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0"> <tr key={d.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
<td className="px-3 py-2 text-xs text-slate-600">{DOSSIER_KIND_LABELS[d.kind] ?? '—'}</td> <td className="px-3 py-2 text-slate-600">{orDash(DOSSIER_KIND_LABELS[d.kind])}</td>
<td className="px-3 py-2 font-medium text-brand-800">{d.name}</td> <td className="px-3 py-2 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}> <span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}>
{DOSSIER_STATUS_LABELS[d.status]} {DOSSIER_STATUS_LABELS[d.status]}
</span> </span>
</td> </td>
<td className="px-3 py-2 text-xs text-slate-600"> <td className="px-3 py-2 text-slate-600">
{d.tvgsName ?? '—'} {orDash(d.tvgsName)}
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>} {d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
</td> </td>
<td className="px-3 py-2 text-xs text-slate-600">{d.note ?? '—'}</td> <td className="px-3 py-2 text-slate-600">{orDash(d.note)}</td>
{isDraft && ( {isDraft && (
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
@ -544,7 +582,7 @@ export function KhkkDetailContent({
} }
> >
{planAttachments.length === 0 ? ( {planAttachments.length === 0 ? (
<div className="text-sm text-muted-foreground">Chưa file đính kèm.</div> <div className="text-sm italic text-slate-400">Chưa file đính kèm.</div>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{planAttachments.map((a) => ( {planAttachments.map((a) => (

View File

@ -23,7 +23,7 @@
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo // (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo
// "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt. // "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt.
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { FileCheck, Inbox, Search } from 'lucide-react' import { ChevronLeft, ChevronRight, FileCheck, Inbox, ListTree, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel' import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
@ -144,8 +144,12 @@ export function KhkkPlanCard({
</div> </div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{plan.maKeHoach ?? '(chưa cấp mã)'}</span> <span className="font-mono">{plan.maKeHoach ?? '(chưa cấp mã)'}</span>
{/* [S175 · B3] Chip nhóm: `text-[9px]` là nấc thứ TƯ của thang chữ (13/11/10/9) —
gom về 3 nấc chuẩn 13/11/10. Và `inline` + cỡ chữ khác dòng chứa ⇒ hộp chip cao
khác chữ bên cạnh nên tụt baseline; `inline-flex items-center leading-none` ghim
nó thẳng hàng thay vì dựa vào baseline của 2 cỡ chữ khác nhau. */}
{plan.approvalGroup != null && ( {plan.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700"> <span className="inline-flex shrink-0 items-center rounded border border-violet-200 bg-violet-50 px-1 py-0.5 text-[10px] font-semibold leading-none text-violet-700">
N{plan.approvalGroup} N{plan.approvalGroup}
</span> </span>
)} )}
@ -227,7 +231,7 @@ export function KhkkListPanel({
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group) const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
if (shown.length === 0) { if (shown.length === 0) {
return ( return (
<p className="px-3 py-2 text-[11px] italic text-slate-400"> <p className="px-3 py-2 text-[11px] italic text-slate-500">
{group === null ? 'Gói này chưa có kế hoạch ký kết.' : `Gói này chưa có phiếu nhóm N${group}.`} {group === null ? 'Gói này chưa có kế hoạch ký kết.' : `Gói này chưa có phiếu nhóm N${group}.`}
</p> </p>
) )
@ -275,23 +279,42 @@ export function KhkkListPanel({
))} ))}
</Select> </Select>
{flatMode && !deletedView && canPe && ( {flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400"> <p className="text-[10px] italic text-slate-500">
Đang lọc danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây). Đang lọc danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây).
</p> </p>
)} )}
{!canPe && ( {!canPe && (
<p className="text-[10px] italic text-slate-400"> <p className="text-[10px] italic text-slate-500">
Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh
sách kế hoạch kết vẫn liệt đy đ dưới. sách kế hoạch kết vẫn liệt đy đ dưới.
</p> </p>
)} )}
</div> </div>
{/* [S175 · B3] ĐẦU CỘT — trước đây chế-độ CÂY đi thẳng vào thân, không tiêu-đề cũng
không số đếm ⇒ cột "cụt đầu": lệch cả với màn Thao tác (`KhkkWorkListPanel` có
`Tier` header) LẪN với chính chế-độ phẳng (có dòng đếm ở chân). Mirror đúng khuôn
`Tier` để 2 màn KHKK cùng một hình. */}
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-brand-100 text-brand-700" aria-hidden>
{flatMode ? <FileCheck className="h-3 w-3" /> : <ListTree className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-wide text-slate-600">
{deletedView ? 'Phiếu đã xóa' : flatMode ? 'Danh sách kế hoạch' : 'Cây toàn trình'}
</span>
{flatMode && !list.isLoading && !list.isError && (
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium tabular-nums text-slate-600">
{total}
</span>
)}
</div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{!flatMode && ( {!flatMode && (
<PipelineTreePanel <PipelineTreePanel
currentStage={2} currentStage={2}
hideHeader hideHeader
hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none" bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }} stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/> />
@ -350,22 +373,28 @@ export function KhkkListPanel({
<FileCheck className="mr-1 inline h-3 w-3" /> <FileCheck className="mr-1 inline h-3 w-3" />
{total} kế hoạch Trang {page} / {totalPages} {total} kế hoạch Trang {page} / {totalPages}
</span> </span>
{/* [S175 · B3] Ký-tự trần `←`/`→` → icon: mũi tên dạng chữ đổi hình theo font hệ
điều-hành và KHÔNG có tên cho trình đọc màn hình. Icon + `aria-label` sửa cả hai. */}
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
type="button" type="button"
onClick={() => onPageChange(Math.max(1, page - 1))} onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1} disabled={page <= 1}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40" aria-label="Trang trước"
title="Trang trước"
className="rounded border border-slate-300 p-1 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
> >
<ChevronLeft className="h-3.5 w-3.5" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages} disabled={page >= totalPages}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40" aria-label="Trang sau"
title="Trang sau"
className="rounded border border-slate-300 p-1 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
> >
<ChevronRight className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>

View File

@ -171,7 +171,7 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!candidates.isLoading && !candidates.isError && awaiting.length === 0 && ( {!candidates.isLoading && !candidates.isError && awaiting.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400"> <p className="p-3 text-[11px] italic text-slate-500">
Mọi phiếu đã duyệt đu đã kế hoạch kết. Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p> </p>
)} )}
@ -181,7 +181,7 @@ export function KhkkWorkListPanel({
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500"> <span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{projectName} {projectName}
</span> </span>
<span className="shrink-0 text-[10px] text-slate-400">{items.length}</span> <span className="shrink-0 text-[10px] text-slate-500">{items.length}</span>
</div> </div>
<ul className="divide-y divide-slate-100"> <ul className="divide-y divide-slate-100">
{items.map((pe) => ( {items.map((pe) => (
@ -241,7 +241,7 @@ export function KhkkWorkListPanel({
</div> </div>
)} )}
{!plans.isLoading && !plans.isError && planItems.length === 0 && ( {!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400"> <p className="p-3 text-[11px] italic text-slate-500">
Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại Không còn kế hoạch nào đang xử . Phiếu đã duyệt không hiện đây xem tại
menu Kế hoạch kết . menu Kế hoạch kết .
</p> </p>
@ -287,7 +287,7 @@ export function KhkkWorkListPanel({
sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc sẽ đọc thành "đang cắt bớt" trong khi thật ra là "đang lọc bỏ". Nói đúng việc
đang làm, và chỉ nói khi thật sự có cái bị giấu. */} đang làm, và chỉ nói khi thật sự có cái bị giấu. */}
{plans.data && plans.data.items.length > planItems.length && ( {plans.data && plans.data.items.length > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400"> <p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-500">
Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết . Đã n kế hoạch trạng thái Đã duyệt xem đ menu Kế hoạch kết .
</p> </p>
)} )}

View File

@ -33,7 +33,8 @@
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Check, History, Paperclip, Send, Trash2, Undo2, Workflow } from 'lucide-react' import { Download, Eye, History, Paperclip, Send, Trash2 } from 'lucide-react'
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -104,6 +105,8 @@ export function KhkkWorkflowPanel({
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
// [S175 · B1] File đang xem-trước inline (khuôn PE `PeWorkflowPanel.tsx:60`).
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
const phase = plan.phase as KhkkPhaseValue const phase = plan.phase as KhkkPhaseValue
const steps = plan.workflowSteps ?? [] const steps = plan.workflowSteps ?? []
@ -174,15 +177,18 @@ export function KhkkWorkflowPanel({
const approvalFiles: KhkkAttachmentDto[] = const approvalFiles: KhkkAttachmentDto[] =
(plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) (plan.attachments ?? []).filter(a => a.purpose === KhkkAttachmentPurpose.ApprovalAttachment)
// [BE-5] Lịch sử thay đổi — chỉ tải khi người dùng mở khối (không tốn 1 request/phiếu). // [BE-5] Lịch sử thay đổi.
const [showChangelog, setShowChangelog] = useState(false) // 🔴 [S175 · B1] Khối này TRƯỚC ĐÂY gập lại và query `enabled: showChangelog` (lazy —
// tiết-kiệm 1 request/phiếu). Đợt này bỏ gập theo khuôn PE ⇒ **BẮT BUỘC bỏ luôn cờ
// `enabled`**: giữ lại thì khối luôn hiện nhưng query KHÔNG BAO GIỜ chạy, và nó hỏng
// dưới dạng "Chưa có thay đổi nào được ghi." — tức trông y hệt phiếu chưa từng sửa,
// KHÔNG phải trông như lỗi. Đổi lấy: +1 request mỗi lần mở phiếu, đúng giá PE đang trả.
const changelogs = useQuery({ const changelogs = useQuery({
queryKey: ['khkk-changelogs', plan.id], queryKey: ['khkk-changelogs', plan.id],
queryFn: async () => queryFn: async () =>
(await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, { (await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, {
params: { take: 200 }, params: { take: 200 },
})).data, })).data,
enabled: showChangelog,
}) })
const transition = useMutation({ const transition = useMutation({
@ -267,41 +273,44 @@ export function KhkkWorkflowPanel({
} }
return ( return (
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}> // [S175 · B1 · X2] Panel TRẦN khuôn PE (`PeWorkflowPanel.tsx:345`). Trước đây bọc
// `card-accent` ⇒ thẻ-trong-thẻ, lệch đập vào mắt trước mọi mục owner liệt kê.
// 🔴 GỠ HẲN CLASS, KHÔNG đè `border-0`/`shadow-none`: `.card-accent` (`index.css:112`)
// nằm NGOÀI `@layer` nên thắng utility Tailwind v4 — đè là vô hiệu (gotcha #66, đã cắn thật).
<div className="space-y-4">
{/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */} {/* ── 1. Header + meta {code} v{NN} · {name} ───────────────────────────── */}
<header className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-5 py-3"> <div>
<span <h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
className="icon-chip"
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
aria-hidden
>
<Workflow className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
{plan.workflowCode && ( {plan.workflowCode && (
<span className="font-mono text-[11px] text-slate-500"> <p className="mt-0.5 font-mono text-[11px] text-slate-500">
{plan.workflowCode} {plan.workflowCode}
{plan.workflowVersion != null && ` v${plan.workflowVersion}`} {plan.workflowVersion != null && ` v${String(plan.workflowVersion).padStart(2, '0')}`}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>} {plan.workflowName && <> · <span className="font-sans">{plan.workflowName}</span></>}
</span> </p>
)} )}
</header> </div>
<div className="space-y-4 p-5"> {/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */} {/* [S175 · B1] violet → EMERALD khuôn PE (`:360-377`). PE dành violet RIÊNG cho
{plan.endedByLevelFinalize && ( "⚑ Duyệt thay CEO"; dùng violet cả ở đây là trộn 2 nghĩa vào một màu. */}
<div className="rounded border border-violet-300 bg-violet-50 px-3 py-2 text-[11px] text-violet-800"> {plan.endedByLevelFinalize && (
<div className="font-medium"> <div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
Kết thúc tại {plan.finalizeStepName ?? 'cấp được cấu hình'} {isApproved ? (
{plan.finalizeLevelName ? ` · ${plan.finalizeLevelName}` : ''} <span>
</div> Kế hoạch đã kết thúc tại{' '}
<div className="mt-0.5"> <span className="font-semibold">{plan.finalizeStepName ?? 'cấp duyệt'}</span>
{isApproved {plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} không qua CEO.
? 'Kế hoạch đã duyệt xong ngay tại cấp này — không qua CEO / Ban Giám đốc.' </span>
: 'Cấp này được cấu hình duyệt là KẾT THÚC — nếu người duyệt tích ô kết thúc thì kế hoạch không qua CEO.'} ) : (
</div> <span>
</div> Quy trình rút gọn: duyệt đến{' '}
)} <span className="font-semibold">{plan.finalizeStepName ?? 'cấp này'}</span>
{plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} KẾT THÚC,
không trình CEO. Các cấp trước vẫn duyệt như thường.
</span>
)}
</div>
)}
{/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */} {/* ── 3. Sơ đồ Bước → Cấp → NV ───────────────────────────────────────── */}
{steps.length > 0 ? ( {steps.length > 0 ? (
@ -317,9 +326,10 @@ export function KhkkWorkflowPanel({
{STATUS_ICON[st]} {STATUS_ICON[st]}
</span> </span>
<span className="text-slate-800">Bước {step.order} {step.name}</span> <span className="text-slate-800">Bước {step.order} {step.name}</span>
{/* Chip PHÒNG của Bước (BE-4 join `Departments`). */} {/* Chip PHÒNG của Bước (BE-4 join `Departments`).
[S175 · B1] viền-xám-nền-trắng → EMERALD đặc khuôn PE (`:406-410`). */}
{step.departmentName && ( {step.departmentName && (
<span className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-600"> <span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName} {step.departmentName}
</span> </span>
)} )}
@ -458,11 +468,11 @@ export function KhkkWorkflowPanel({
}} }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'} title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition', 'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50', blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
)} )}
> >
<Check className="h-3 w-3" /> Duyệt Duyệt
</button> </button>
<button <button
type="button" type="button"
@ -470,11 +480,11 @@ export function KhkkWorkflowPanel({
onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }} onClick={() => { if (!blockedByLevel) { setComment(''); setPendingFiles([]); setAction(KhkkTransitionAction.Return) } }}
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'} title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition', 'rounded border px-2 py-1 text-[11px] font-bold transition',
blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50', blockedByLevel ? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400' : 'border-amber-300 text-amber-700 hover:bg-amber-50',
)} )}
> >
<Undo2 className="h-3 w-3" /> Trả lại Trả lại
</button> </button>
{/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối {/* 🧊 [S174 owner] Nút "TỪ CHỐI" ĐÃ GỠ — anh chốt "vậy thì bỏ luôn từ chối
~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện ~ tương tự PE". Duyệt NCC có code từ-chối nhưng THỰC TẾ không hiện
@ -488,7 +498,8 @@ export function KhkkWorkflowPanel({
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-[11px] font-bold text-slate-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700" title="Xóa phiếu này (phiếu chuyển sang mục &ldquo;Đã xóa&rdquo;)"
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
> >
<Trash2 className="h-3 w-3" /> Xóa phiếu <Trash2 className="h-3 w-3" /> Xóa phiếu
</button> </button>
@ -498,32 +509,52 @@ export function KhkkWorkflowPanel({
)} )}
{/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */} {/* ── 5. 📎 File đính kèm khi duyệt — TỰ ẨN khi rỗng (khuôn PE :928-967) ── */}
{/* [S175 · B1] +`border-t pt-4` +`<h3 text-sm>` +nút `<Eye>` xem trước.
Route BE ĐÃ CÓ `ContractSigningPlansController.cs:231` (`/view`, cùng hình với
PE `:317`) ⇒ chỉ cần truyền `basePath` cho dialog dùng chung.
🔴 Nhãn "Tải xuống" chuyển vào `title=` chứ KHÔNG bỏ: giữ đúng chuỗi đó trong
bundle để phép verify prod còn control-dương soi được. */}
{approvalFiles.length > 0 && ( {approvalFiles.length > 0 && (
<div> <div className="border-t border-slate-200 pt-4">
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700"> <h3 className="text-sm font-semibold text-slate-900">📎 File đính kèm khi duyệt</h3>
<Paperclip className="h-3.5 w-3.5 text-teal-500" /> <p className="mt-0.5 text-[11px] text-slate-500">File do người duyệt tải lên trong quá trình duyệt.</p>
File đính kèm khi duyệt <div className="mt-2 space-y-1">
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{approvalFiles.length}
</span>
</div>
<ul className="mt-1.5 space-y-1.5">
{approvalFiles.map(a => ( {approvalFiles.map(a => (
<li key={a.id} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]"> <div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<div className="min-w-0"> <Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<div className="truncate font-medium text-slate-700">{a.fileName}</div> <span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>{a.fileName}</span>
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div> <span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
</div> {isPreviewable(a.fileName) && (
<button
type="button"
onClick={() => setPreviewAtt(a)}
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
title="Xem trước"
>
<Eye className="h-3 w-3" />
</button>
)}
<button <button
type="button" type="button"
onClick={() => downloadAttachment(a)} onClick={() => downloadAttachment(a)}
className="shrink-0 text-[11px] font-medium text-brand-600 hover:underline" className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50"
title="Tải xuống"
> >
Tải xuống <Download className="h-3 w-3" />
</button> </button>
</li> </div>
))} ))}
</ul> </div>
{previewAtt && (
<AttachmentPreviewDialog
open
basePath="/contract-signing-plans"
evaluationId={plan.id}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
</div> </div>
)} )}
@ -531,61 +562,63 @@ export function KhkkWorkflowPanel({
{/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây {/* [B4/D-11] BE ĐÃ LỌC hàng `decision = Pending` (hành-vi GỬI duyệt) ⇒ số (N) ở đây
CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn CÙNG NGHĨA với (N) của Duyệt NCC. FE KHÔNG dedupe lại; sự kiện "Gửi duyệt" vẫn
còn nguyên ở "Lịch sử thay đổi" bên dưới. */} còn nguyên ở "Lịch sử thay đổi" bên dưới. */}
<div> {/* [S175 · B1] Bỏ pill đếm → `(n)` trong ngoặc · thẻ `bg-slate-50/60 text-[11px]`
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700"> → `bg-white p-3 text-sm` · mốc-giờ ĐẨY PHẢI bằng `justify-between`.
<Check className="h-3.5 w-3.5 text-emerald-500" /> 🔴 Badge 2-ngả → 3-NGẢ: trước đây MỌI thứ không phải `Reject` đều tô emerald
Lịch sử duyệt ⇒ "Trả lại" (amber ở PE) bị vẽ xanh như đã-duyệt — đọc ngược nghĩa.
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500"> `toPhase` đã có sẵn (`khkk.ts:307`) nên phân được TraLai ⟂ TuChoi ⟂ Duyệt. */}
{approvals.length} <div className="border-t border-slate-200 pt-4">
</span> <h3 className="text-sm font-semibold text-slate-900">Lịch sử duyệt ({approvals.length})</h3>
</div>
{approvals.length === 0 ? ( {approvals.length === 0 ? (
<p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p> <p className="mt-1.5 text-[11px] italic text-slate-400">Chưa lượt duyệt nào.</p>
) : ( ) : (
<ul className="mt-1.5 space-y-1.5"> <ul className="mt-2 space-y-1.5">
{approvals.map(a => ( {approvals.map(a => {
<li key={a.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]"> const isReject = a.toPhase === KhkkPhase.TuChoi
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700"> const isReturn = a.toPhase === KhkkPhase.TraLai
<span className={cn( return (
'rounded px-1.5 py-0.5 text-[9px] font-semibold', <li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
a.decision === KhkkApprovalDecision.Reject ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800', <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 text-slate-700">
)}> <div className="flex flex-wrap items-center gap-x-1.5">
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'} <span className={cn(
</span> 'rounded px-1.5 py-0.5 text-[10px] font-semibold',
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span> isReject ? 'bg-rose-100 text-rose-800'
<span className="text-slate-400">·</span> : isReturn ? 'bg-amber-100 text-amber-800'
<span className="text-slate-500">{formatDateTime(a.approvedAt)}</span> : 'bg-emerald-100 text-emerald-800',
</div> )}>
<div className="mt-0.5 text-slate-600"> {DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'} </span>
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`} <span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
{a.levelName ? ` (${a.levelName})` : ''} </div>
<span className="text-slate-400"> <span className="text-[11px] text-slate-500">{formatDateTime(a.approvedAt)}</span>
{' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]} </div>
</span> <div className="mt-1 text-[12px] text-slate-600">
</div> {a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.comment?.trim() && ( {a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">{a.comment}</div> {a.levelName ? ` (${a.levelName})` : ''}
)} <span className="text-slate-400">
</li> {' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]}
))} </span>
</div>
{a.comment?.trim() && (
<div className="mt-1 whitespace-pre-wrap text-[12px] text-slate-600">{a.comment}</div>
)}
</li>
)
})}
</ul> </ul>
)} )}
</div> </div>
{/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */} {/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */}
<div> {/* [S175 · B1] BỎ GẬP — PE render thẳng (`PeDetailTabs.tsx:787-794`). Nội-dung
<button đã tải sẵn qua query; giấu sau 1 cú bấm chỉ thêm ma-sát, không tiết-kiệm gì. */}
type="button" <div className="border-t border-slate-200 pt-4">
onClick={() => setShowChangelog(v => !v)} <h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-900">
className="flex w-full items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-brand-700"
>
<History className="h-3.5 w-3.5 text-slate-400" /> <History className="h-3.5 w-3.5 text-slate-400" />
Lịch sử thay đi Lịch sử thay đi
<span className="ml-auto text-[10px] font-normal text-slate-400">{showChangelog ? 'Ẩn' : 'Xem'}</span> </h3>
</button> <div className="mt-2">
{showChangelog && (
<div className="mt-1.5">
{changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>} {changelogs.isLoading && <p className="text-[11px] text-slate-400">Đang tải</p>}
{changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>} {changelogs.isError && <p className="text-[11px] text-red-600">Không tải đưc lịch sử thay đi.</p>}
{changelogs.data && changelogs.data.length === 0 && ( {changelogs.data && changelogs.data.length === 0 && (
@ -614,10 +647,8 @@ export function KhkkWorkflowPanel({
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
)}
</div> </div>
</div>
{action !== null && ( {action !== null && (
<Dialog <Dialog
@ -625,7 +656,7 @@ export function KhkkWorkflowPanel({
onClose={() => setAction(null)} onClose={() => setAction(null)}
title={dialogTitle} title={dialogTitle}
footer={<> footer={<>
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button> <Button variant="outline" onClick={() => setAction(null)}>Hủy</Button>
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}> <Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'} {transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button> </Button>
@ -663,22 +694,26 @@ export function KhkkWorkflowPanel({
</label> </label>
</div> </div>
)} )}
{/* [S175 · B1 micro-copy] Nhãn Ô NHẬP theo PE (`PeWorkflowPanel.tsx:869` "Ghi chú
(tùy chọn)"). 🔴 CHỈ đổi tên TRƯỜNG — chuỗi "Ý kiến cấp duyệt" ở dòng trợ-giúp
bên dưới là TÊN THẬT của mục đích đến trong phiếu (Section 5, Mig 26), đổi nó
là trỏ người dùng tới một mục không tồn tại. */}
<Label> <Label>
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'} Ghi chú{commentRequired ? <span className="text-red-500"> *</span> : ' (tùy chọn)'}
</Label> </Label>
<Textarea <Textarea
value={comment} value={comment}
onChange={e => setComment(e.target.value)} onChange={e => setComment(e.target.value)}
rows={3} rows={3}
maxLength={2000} maxLength={2000}
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'} placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ghi chú của bạn (ghi vào phiếu)...'}
/> />
{/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng {/* [QĐ-3 / BE-7] File đính kèm khi duyệt — upload THẬT qua đường riêng
`POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */} `POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */}
{action !== KhkkTransitionAction.Submit && ( {action !== KhkkTransitionAction.Submit && (
<div className="mt-3"> <div className="mt-3">
<Label className="text-xs">File đính kèm (tuỳ chọn)</Label> <Label className="text-xs">File đính kèm (tùy chọn)</Label>
<div className="mt-1 flex flex-wrap items-center gap-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
@ -714,7 +749,7 @@ export function KhkkWorkflowPanel({
{action === KhkkTransitionAction.Approve && ( {action === KhkkTransitionAction.Approve && (
<p className="mt-1 text-[11px] text-slate-500"> <p className="mt-1 text-[11px] text-slate-500">
Ý kiến sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại Ghi chú sẽ đưc ghi vào mục &ldquo;Ý kiến cấp duyệt&rdquo; của kế hoạch (1 ô/Cấp duyệt lại
thì ghi đè ô ). thì ghi đè ô ).
</p> </p>
)} )}
@ -727,7 +762,7 @@ export function KhkkWorkflowPanel({
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
title="🗑 Xóa kế hoạch ký kết" title="🗑 Xóa kế hoạch ký kết"
footer={<> footer={<>
<Button variant="outline" onClick={() => setConfirmDelete(false)}>Huỷ</Button> <Button variant="outline" onClick={() => setConfirmDelete(false)}>Hủy</Button>
<Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}> <Button onClick={() => removePlan.mutate()} disabled={removePlan.isPending}>
{removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'} {removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'}
</Button> </Button>
@ -739,6 +774,6 @@ export function KhkkWorkflowPanel({
</div> </div>
</Dialog> </Dialog>
)} )}
</section> </div>
) )
} }

View File

@ -15,10 +15,15 @@ export function isPreviewable(fileName: string): boolean {
type Props = { type Props = {
open: boolean open: boolean
/** Id của bản-ghi CHỦ (phiếu PE, hoặc kế-hoạch KHKK khi truyền `basePath`). */
evaluationId: string evaluationId: string
attachmentId: string attachmentId: string
fileName: string fileName: string
onClose: () => void onClose: () => void
/** [S175 B1] Prefix route của module chủ. Mặc-định `/purchase-evaluations` ⇒ **6 call-site
* PE hiện có KHÔNG phải sửa dòng nào** (module đã golive — mở trục mới mà bắt sửa hết
* call-site cũ là tự đẻ rủi-ro ở chỗ đang chạy tốt). KHKK truyền `/contract-signing-plans`. */
basePath?: string
} }
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline). /** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
@ -29,6 +34,7 @@ type Props = {
* cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */ * cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */
export function AttachmentPreviewDialog({ export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose, open, evaluationId, attachmentId, fileName, onClose,
basePath = '/purchase-evaluations',
}: Props) { }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null) const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -58,7 +64,7 @@ export function AttachmentPreviewDialog({
setLoading(true) setLoading(true)
setError(null) setError(null)
api.get(`/purchase-evaluations/${evaluationId}/attachments/${attachmentId}/view`, { api.get(`${basePath}/${evaluationId}/attachments/${attachmentId}/view`, {
responseType: 'blob', responseType: 'blob',
}) })
.then(res => { .then(res => {
@ -78,7 +84,7 @@ export function AttachmentPreviewDialog({
if (currentUrl) window.URL.revokeObjectURL(currentUrl) if (currentUrl) window.URL.revokeObjectURL(currentUrl)
setBlobUrl(null) setBlobUrl(null)
} }
}, [open, evaluationId, attachmentId]) }, [open, evaluationId, attachmentId, basePath])
const ready = blobUrl && !loading && !error const ready = blobUrl && !loading && !error

View File

@ -89,6 +89,7 @@ export function PipelineTreePanel({
bodyMaxHClass, bodyMaxHClass,
stage2Options, stage2Options,
hideHeader, hideHeader,
hideSearch,
}: { }: {
currentStage: PipelineStageNo currentStage: PipelineStageNo
className?: string className?: string
@ -107,6 +108,12 @@ export function PipelineTreePanel({
// 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi // 🔴 Nút "Hiện/Ẩn cây" (mobile) sống TRONG thanh này và là đường DUY NHẤT mở thân cây khi
// `<lg`. Vì vậy ẩn thanh ⇒ PHẢI ép thân cây hiện luôn, nếu không mobile mất trắng cây. // `<lg`. Vì vậy ẩn thanh ⇒ PHẢI ép thân cây hiện luôn, nếu không mobile mất trắng cây.
hideHeader?: boolean hideHeader?: boolean
// [S175 · B3] Ẩn ô lọc RIÊNG của cây. CHỈ dùng ở host đã có ô tìm của chính nó ngay trên
// (KHKK GĐ2) — ở đó 2 ô tìm xếp chồng nhau mà nghĩa lại khác: ô ngoài đi SERVER-SIDE và
// gõ vào là chuyển hẳn sang danh-sách phẳng (`flatMode`), nên ô trong cây KHÔNG bao giờ
// dùng tới — người dùng gõ phát nào là cây biến mất phát đó.
// 6 host còn lại KHÔNG truyền ⇒ giữ nguyên ô lọc cây như cũ.
hideSearch?: boolean
}) { }) {
const { canPe, buildStages } = usePipelineStages() const { canPe, buildStages } = usePipelineStages()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -232,16 +239,18 @@ export function PipelineTreePanel({
{/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `<lg` vẫn thấy cây. */} {/* [S174] `hideHeader` ⇒ không còn nút toggle ⇒ ép `flex` để `<lg` vẫn thấy cây. */}
<div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}> <div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
<div className="relative border-b border-slate-200 p-2"> {!hideSearch && (
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" /> <div className="relative border-b border-slate-200 p-2">
<input <Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
value={search} <input
onChange={e => setSearch(e.target.value)} value={search}
placeholder="Lọc dự án / hạng mục / mã phiếu…" onChange={e => setSearch(e.target.value)}
aria-label="Lọc cây toàn trình" placeholder="Lọc dự án / hạng mục / mã phiếu…"
className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30" aria-label="Lọc cây toàn trình"
/> className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
</div> />
</div>
)}
<div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}> <div className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}>
{!canPe && ( {!canPe && (

View File

@ -3,8 +3,14 @@
// //
// [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện // [S174 owner] ĐỔI TỪ TRANG-GIẤY-CUỘN SANG HOST 2-PANEL — anh chốt: **"làm giống giao diện
// thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về: // thao tác của Duyệt NCC"** (`pages/pe/PurchaseEvaluationWorkspacePage.tsx`). Khuôn bê về:
// · header 1 dòng + grid `lg:grid-cols-[300px_1fr]`; // · header 1 dòng + grid `lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]` — ĐÚNG BYTE
// · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy; // khuôn PE (`PurchaseEvaluationWorkspacePage.tsx:80`). 🧊 Comment cũ ghi `300px` là số
// của bản nháp đầu, KHÔNG bao giờ khớp code sau khi đợt A land 260/320 (S175 vá).
// · Panel 1 = danh sách (`KhkkWorkListPanel`) — 2 tầng, MỖI tầng có header dính ĐỈNH
// (`sticky top-0`) gom theo dự án. 🧊 Comment cũ ghi "nút hành-động dính đáy" là MÔ TẢ
// CỦA PE, không phải của đây: `grep -c Button KhkkWorkListPanel` = **0** (control-dương
// `PeListPanel` = 8). Bê nguyên câu mô tả từ khuôn nguồn sang là tự khai một thứ chưa
// dựng — S175 vá theo L3 H-5.
// · Panel 2 = EmptyState | form tạo | phiếu đang XEM. // · Panel 2 = EmptyState | form tạo | phiếu đang XEM.
// 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC" // 🔴 Panel 1 chia 2 TẦNG theo đúng ảnh anh khoanh: trên = "Cột danh sách Duyệt NCC"
// (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*). // (bấm ⇒ *tạo luôn KHKK*), dưới = "Cột KHKK đã tạo" (bấm ⇒ *chỉ xem*).