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
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:
- [x] bootstrap: Sàn-3 SẠCH 5/5 · scaffold session-11 · tick 46→47 squash-benign · test 644 PASS/0 Fail
- [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] 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] _mind-s-11 MIND-0+MIND-1; mind-check `dat=11 TRUOT=0` exit 0 · _context PAUSE-1 + §(c) 16 file
- [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] 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
- [!] 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
- [ ] `STATUS:473` Gotchas 86→87 (đầu chuỗi B1 ⇒ 3 bề mặt sai theo)
- [ ] 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)
- [ ] đư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)
- [ ] 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)
- [ ] slot (70) MỞ LẠI — vá anchor `^\*\*` phải neo CẢ `**`/`##`/`###` (3 khối mù `:27` `:68` `:102`)
- [ ] monthly drift-audit (quá hạn 4 ngày; ring1 phân xử CHƯA chạy, lần cuối `881783c` 07-01)
- [ ] 🔄 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>/`)
- [ ] 🔴 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
- [ ] 🔄 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.
- [ ] 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] 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 commit ship + 3 đợt deploy XANH (verify byte-level: hash ĐỔI ∧ chuỗi mới CÓ, 2 vế): `14a917f` `3f339c0` `f86f30d` `d0453ba` `a1b9cbb`
- [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] 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] đợ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] 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ổ)
- [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
- [ ] đợ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
- [ ] đợ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
- [ ] 🔴 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)
- [ ] 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ù)
- [ ] ghi synthesis cho run-folder **S173** `2026-08-05-S173-bookend-open` (vẫn ORPHAN, 0 flat synthesis)
- [ ] 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
- [ ] `ring2-audit/MEMORY.md` 26.440 B > cap 25.600 — A1 treo từ PAUSE-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
- [ ] monthly drift-audit quá hạn · dọn 9 dir rỗng `runs/.../agent-memory/*` @session-end
- [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.
- [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)
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
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)
- `grep -c 'ROTATE' docs/HANDOFF.md` → 0 = vrapp còn mất (control-dương: `grep -c ZZTEST` → 3)
- `grep -c 'Carry @S172' docs/HANDOFF.md`1 = chưa re-stamp
- `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 '<SoonChip' fe-admin/src/components/pipeline/PipelineStageFolders.tsx` → 0 (control-dương: `grep -c 'SoonChip'` → 2, chỉ còn trong comment)
- `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)
- `ls .claude/workflows/runs/2026-08-05-S173-bookend-open/*-synthesis.md` → no match = còn orphan
- `ls .claude/sessions/session-11/` → _context-s-11.md · _mind-s-11.md · _pause-1.md · _tiep-1.md
- `sed -n '473p' docs/STATUS.md | grep -c 'Gotchas | \*\*86\*\*'` → 1 = chưa vá (literal có `**` bold)
- `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.
## 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].
- **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)")_

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).",
"_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,
"last_ticked_session": "S173",
"last_ticked_head": "1b3bcaa2e26c6c722dd2abde2f359fedf58d5ea7",
"counter": 48,
"last_ticked_session": "S174",
"last_ticked_head": "7fcf0ab5e9d86b85a36d776783d30d3cc174fb3f",
"last_ticked_at": "2026-08-05",
"last_audit": {
"light_at_counter": 46,
@ -242,6 +242,11 @@
"at": "2026-08-05",
"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)."
},
{
"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" }
```
### 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
@ -110,3 +140,16 @@
| `…/harness-refine-return.md` | A | 40.048 B — `1 action / 15 bác` |
| `…/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** |
> **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 -->
## 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)
### 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 |
|---|---|---|
| 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 |
| 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 |
| 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****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 |
| 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 |
@ -52,10 +52,27 @@ gỡ cây khỏi Hộp thư. Prod hiện tại: admin `index-BXwdUVmD.js` · eof
## Stages
- [ ] L1..L5 chạy song song (hmw parallel)
- [ ] lead refute + synthesize (P3/P4)
- [ ] lead ghi `spec-khkk-style-dong-bo-05-08-2026.md` — 3 heading: ① đề-bài ② cách implement ③ checklist acceptance ĐO ĐƯỢC
- [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
- [x] lead refute + synthesize (P3/P4) — tự verify 2 claim nặng nhất, cả 2 lane ĐÚNG (dưới)
- [x] lead ghi `spec-khkk-style-dong-bo-05-08-2026.md` — 3 heading đủ
## 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
@ -48,6 +48,21 @@ function formatVnd(n: number | null | undefined): string {
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 {
return new Date(iso).toLocaleString('vi-VN')
}
@ -362,17 +377,32 @@ export function KhkkDetailContent({
chipFg="var(--color-teal-700)"
actions={
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">
🔒 Giá đã chốt tại thời điểm duyệt
// [S175 · B2 · P0] emoji 🔒 → icon lucide: emoji render khác nhau theo font hệ
// đ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>
) : undefined
}
>
{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">
<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">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
@ -385,16 +415,16 @@ export function KhkkDetailContent({
</thead>
<tbody>
{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">
{l.supplierName ?? '—'}
{orDash(l.supplierName)}
{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>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-700">
<td className="px-3 py-2 text-slate-700">
{isDraft && !l.contractId ? (
<>
<select
@ -426,12 +456,12 @@ export function KhkkDetailContent({
</div>
)}
</>
) : l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>}
) : orDash(l.tenHangMuc)}
</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-brand-800">{formatVnd(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-xs text-slate-600">{l.note ?? '—'}</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">{moneyOrDash(l.proposedAmount)}</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-slate-600">{orDash(l.note)}</td>
</tr>
))}
</tbody>
@ -462,10 +492,18 @@ export function KhkkDetailContent({
}
>
{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">
<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">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Loại</th>
@ -478,19 +516,19 @@ export function KhkkDetailContent({
</thead>
<tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-2 text-xs text-slate-600">{DOSSIER_KIND_LABELS[d.kind] ?? '—'}</td>
<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-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">
<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]}
</span>
</td>
<td className="px-3 py-2 text-xs text-slate-600">
{d.tvgsName ?? '—'}
<td className="px-3 py-2 text-slate-600">
{orDash(d.tvgsName)}
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
</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 && (
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
@ -544,7 +582,7 @@ export function KhkkDetailContent({
}
>
{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">
{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
// "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 { FileCheck, Inbox, Search } from 'lucide-react'
import { ChevronLeft, ChevronRight, FileCheck, Inbox, ListTree, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
@ -144,8 +144,12 @@ export function KhkkPlanCard({
</div>
<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>
{/* [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 && (
<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}
</span>
)}
@ -227,7 +231,7 @@ export function KhkkListPanel({
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
if (shown.length === 0) {
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}.`}
</p>
)
@ -275,23 +279,42 @@ export function KhkkListPanel({
))}
</Select>
{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).
</p>
)}
{!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
sách kế hoạch kết vẫn liệt đy đ dưới.
</p>
)}
</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">
{!flatMode && (
<PipelineTreePanel
currentStage={2}
hideHeader
hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/>
@ -350,22 +373,28 @@ export function KhkkListPanel({
<FileCheck className="mr-1 inline h-3 w-3" />
{total} kế hoạch Trang {page} / {totalPages}
</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">
<button
type="button"
onClick={() => onPageChange(Math.max(1, 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
type="button"
onClick={() => onPageChange(page + 1)}
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>
</div>
</div>

View File

@ -171,7 +171,7 @@ export function KhkkWorkListPanel({
</div>
)}
{!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.
</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">
{projectName}
</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>
<ul className="divide-y divide-slate-100">
{items.map((pe) => (
@ -241,7 +241,7 @@ export function KhkkWorkListPanel({
</div>
)}
{!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
menu Kế hoạch kết .
</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
đ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 && (
<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 .
</p>
)}

View File

@ -33,7 +33,8 @@
import { useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
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 { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
@ -104,6 +105,8 @@ export function KhkkWorkflowPanel({
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
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 steps = plan.workflowSteps ?? []
@ -174,15 +177,18 @@ export function KhkkWorkflowPanel({
const approvalFiles: KhkkAttachmentDto[] =
(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).
const [showChangelog, setShowChangelog] = useState(false)
// [BE-5] Lịch sử thay đổi.
// 🔴 [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({
queryKey: ['khkk-changelogs', plan.id],
queryFn: async () =>
(await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, {
params: { take: 200 },
})).data,
enabled: showChangelog,
})
const transition = useMutation({
@ -267,41 +273,44 @@ export function KhkkWorkflowPanel({
}
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} ───────────────────────────── */}
<header className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
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>
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
{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.workflowVersion != null && ` v${plan.workflowVersion}`}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
</span>
{plan.workflowVersion != null && ` v${String(plan.workflowVersion).padStart(2, '0')}`}
{plan.workflowName && <> · <span className="font-sans">{plan.workflowName}</span></>}
</p>
)}
</header>
</div>
<div className="space-y-4 p-5">
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
{plan.endedByLevelFinalize && (
<div className="rounded border border-violet-300 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
<div className="font-medium">
Kết thúc tại {plan.finalizeStepName ?? 'cấp được cấu hình'}
{plan.finalizeLevelName ? ` · ${plan.finalizeLevelName}` : ''}
</div>
<div className="mt-0.5">
{isApproved
? 'Kế hoạch đã duyệt xong ngay tại cấp này — không qua CEO / Ban Giám đốc.'
: '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>
</div>
)}
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
{/* [S175 · B1] violet → EMERALD khuôn PE (`:360-377`). PE dành violet RIÊNG cho
"⚑ Duyệt thay CEO"; dùng violet cả ở đây là trộn 2 nghĩa vào một màu. */}
{plan.endedByLevelFinalize && (
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
{isApproved ? (
<span>
Kế hoạch đã kết thúc tại{' '}
<span className="font-semibold">{plan.finalizeStepName ?? 'cấp duyệt'}</span>
{plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} không qua CEO.
</span>
) : (
<span>
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 ───────────────────────────────────────── */}
{steps.length > 0 ? (
@ -317,9 +326,10 @@ export function KhkkWorkflowPanel({
{STATUS_ICON[st]}
</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 && (
<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}
</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'}
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',
)}
>
<Check className="h-3 w-3" /> Duyệt
Duyệt
</button>
<button
type="button"
@ -470,11 +480,11 @@ export function KhkkWorkflowPanel({
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'}
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',
)}
>
<Undo2 className="h-3 w-3" /> Trả lại
Trả lại
</button>
{/* 🧊 [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
@ -488,7 +498,8 @@ export function KhkkWorkflowPanel({
<button
type="button"
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
</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) ── */}
{/* [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 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Paperclip className="h-3.5 w-3.5 text-teal-500" />
File đính kèm khi duyệt
<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">
<div className="border-t border-slate-200 pt-4">
<h3 className="text-sm font-semibold text-slate-900">📎 File đính kèm khi duyệt</h3>
<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>
<div className="mt-2 space-y-1">
{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 className="min-w-0">
<div className="truncate font-medium text-slate-700">{a.fileName}</div>
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
</div>
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>{a.fileName}</span>
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
{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
type="button"
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>
</li>
</div>
))}
</ul>
</div>
{previewAtt && (
<AttachmentPreviewDialog
open
basePath="/contract-signing-plans"
evaluationId={plan.id}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
</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
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. */}
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Check className="h-3.5 w-3.5 text-emerald-500" />
Lịch sử duyệt
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{approvals.length}
</span>
</div>
{/* [S175 · B1] Bỏ pill đếm → `(n)` trong ngoặc · thẻ `bg-slate-50/60 text-[11px]`
→ `bg-white p-3 text-sm` · mốc-giờ ĐẨY PHẢI bằng `justify-between`.
🔴 Badge 2-ngả → 3-NGẢ: trước đây MỌI thứ không phải `Reject` đều tô emerald
⇒ "Trả lại" (amber ở PE) bị vẽ xanh như đã-duyệt — đọc ngược nghĩa.
`toPhase` đã có sẵn (`khkk.ts:307`) nên phân được TraLai ⟂ TuChoi ⟂ Duyệt. */}
<div className="border-t border-slate-200 pt-4">
<h3 className="text-sm font-semibold text-slate-900">Lịch sử duyệt ({approvals.length})</h3>
{approvals.length === 0 ? (
<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">
{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]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className={cn(
'rounded px-1.5 py-0.5 text-[9px] font-semibold',
a.decision === KhkkApprovalDecision.Reject ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800',
)}>
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
</span>
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(a.approvedAt)}</span>
</div>
<div className="mt-0.5 text-slate-600">
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
{a.levelName ? ` (${a.levelName})` : ''}
<span className="text-slate-400">
{' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]}
</span>
</div>
{a.comment?.trim() && (
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">{a.comment}</div>
)}
</li>
))}
<ul className="mt-2 space-y-1.5">
{approvals.map(a => {
const isReject = a.toPhase === KhkkPhase.TuChoi
const isReturn = a.toPhase === KhkkPhase.TraLai
return (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<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">
<span className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-semibold',
isReject ? 'bg-rose-100 text-rose-800'
: isReturn ? 'bg-amber-100 text-amber-800'
: 'bg-emerald-100 text-emerald-800',
)}>
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
</span>
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
</div>
<span className="text-[11px] text-slate-500">{formatDateTime(a.approvedAt)}</span>
</div>
<div className="mt-1 text-[12px] text-slate-600">
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
{a.levelName ? ` (${a.levelName})` : ''}
<span className="text-slate-400">
{' '}· {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>
)}
</div>
{/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */}
<div>
<button
type="button"
onClick={() => setShowChangelog(v => !v)}
className="flex w-full items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-brand-700"
>
{/* [S175 · B1] BỎ GẬP — PE render thẳng (`PeDetailTabs.tsx:787-794`). Nội-dung
đã 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ì. */}
<div className="border-t border-slate-200 pt-4">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-900">
<History className="h-3.5 w-3.5 text-slate-400" />
Lịch sử thay đi
<span className="ml-auto text-[10px] font-normal text-slate-400">{showChangelog ? 'Ẩn' : 'Xem'}</span>
</button>
{showChangelog && (
<div className="mt-1.5">
</h3>
<div className="mt-2">
{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.data && changelogs.data.length === 0 && (
@ -614,10 +647,8 @@ export function KhkkWorkflowPanel({
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
{action !== null && (
<Dialog
@ -625,7 +656,7 @@ export function KhkkWorkflowPanel({
onClose={() => setAction(null)}
title={dialogTitle}
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}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button>
@ -663,22 +694,26 @@ export function KhkkWorkflowPanel({
</label>
</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>
Ý 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>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
rows={3}
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
`POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */}
{action !== KhkkTransitionAction.Submit && (
<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">
<input
ref={fileInputRef}
@ -714,7 +749,7 @@ export function KhkkWorkflowPanel({
{action === KhkkTransitionAction.Approve && (
<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 đè ô ).
</p>
)}
@ -727,7 +762,7 @@ export function KhkkWorkflowPanel({
onClose={() => setConfirmDelete(false)}
title="🗑 Xóa kế hoạch ký kết"
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}>
{removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'}
</Button>
@ -739,6 +774,6 @@ export function KhkkWorkflowPanel({
</div>
</Dialog>
)}
</section>
</div>
)
}

View File

@ -15,10 +15,15 @@ export function isPreviewable(fileName: string): boolean {
type Props = {
open: boolean
/** Id của bản-ghi CHỦ (phiếu PE, hoặc kế-hoạch KHKK khi truyền `basePath`). */
evaluationId: string
attachmentId: string
fileName: string
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).
@ -29,6 +34,7 @@ type Props = {
* cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */
export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose,
basePath = '/purchase-evaluations',
}: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
@ -58,7 +64,7 @@ export function AttachmentPreviewDialog({
setLoading(true)
setError(null)
api.get(`/purchase-evaluations/${evaluationId}/attachments/${attachmentId}/view`, {
api.get(`${basePath}/${evaluationId}/attachments/${attachmentId}/view`, {
responseType: 'blob',
})
.then(res => {
@ -78,7 +84,7 @@ export function AttachmentPreviewDialog({
if (currentUrl) window.URL.revokeObjectURL(currentUrl)
setBlobUrl(null)
}
}, [open, evaluationId, attachmentId])
}, [open, evaluationId, attachmentId, basePath])
const ready = blobUrl && !loading && !error

View File

@ -89,6 +89,7 @@ export function PipelineTreePanel({
bodyMaxHClass,
stage2Options,
hideHeader,
hideSearch,
}: {
currentStage: PipelineStageNo
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
// `<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
// [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 [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. */}
<div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
<div className="relative border-b border-slate-200 p-2">
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Lọc dự án / hạng mục / mã phiếu…"
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>
{!hideSearch && (
<div className="relative border-b border-slate-200 p-2">
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Lọc dự án / hạng mục / mã phiếu…"
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 className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}>
{!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
// 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]`;
// · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy;
// · header 1 dòng + grid `lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]` — ĐÚNG BYTE
// 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 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*).

View File

@ -21,7 +21,7 @@
import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClipboardList, FileCheck, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react'
import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
@ -48,6 +48,21 @@ function formatVnd(n: number | null | undefined): string {
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 {
return new Date(iso).toLocaleString('vi-VN')
}
@ -362,17 +377,32 @@ export function KhkkDetailContent({
chipFg="var(--color-teal-700)"
actions={
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">
🔒 Giá đã chốt tại thời điểm duyệt
// [S175 · B2 · P0] emoji 🔒 → icon lucide: emoji render khác nhau theo font hệ
// đ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>
) : undefined
}
>
{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">
<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">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Nhà cung cấp</th>
@ -385,16 +415,16 @@ export function KhkkDetailContent({
</thead>
<tbody>
{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">
{l.supplierName ?? '—'}
{orDash(l.supplierName)}
{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>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-700">
<td className="px-3 py-2 text-slate-700">
{isDraft && !l.contractId ? (
<>
<select
@ -426,12 +456,12 @@ export function KhkkDetailContent({
</div>
)}
</>
) : l.tenHangMuc ? l.tenHangMuc : <span className="text-slate-300"></span>}
) : orDash(l.tenHangMuc)}
</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-brand-800">{formatVnd(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-xs text-slate-600">{l.note ?? '—'}</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">{moneyOrDash(l.proposedAmount)}</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-slate-600">{orDash(l.note)}</td>
</tr>
))}
</tbody>
@ -462,10 +492,18 @@ export function KhkkDetailContent({
}
>
{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">
<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">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Loại</th>
@ -478,19 +516,19 @@ export function KhkkDetailContent({
</thead>
<tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => (
<tr key={d.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-2 text-xs text-slate-600">{DOSSIER_KIND_LABELS[d.kind] ?? '—'}</td>
<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-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">
<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]}
</span>
</td>
<td className="px-3 py-2 text-xs text-slate-600">
{d.tvgsName ?? '—'}
<td className="px-3 py-2 text-slate-600">
{orDash(d.tvgsName)}
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
</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 && (
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
@ -544,7 +582,7 @@ export function KhkkDetailContent({
}
>
{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">
{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
// "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 { FileCheck, Inbox, Search } from 'lucide-react'
import { ChevronLeft, ChevronRight, FileCheck, Inbox, ListTree, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
@ -144,8 +144,12 @@ export function KhkkPlanCard({
</div>
<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>
{/* [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 && (
<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}
</span>
)}
@ -227,7 +231,7 @@ export function KhkkListPanel({
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
if (shown.length === 0) {
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}.`}
</p>
)
@ -275,23 +279,42 @@ export function KhkkListPanel({
))}
</Select>
{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).
</p>
)}
{!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
sách kế hoạch kết vẫn liệt đy đ dưới.
</p>
)}
</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">
{!flatMode && (
<PipelineTreePanel
currentStage={2}
hideHeader
hideSearch
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/>
@ -350,22 +373,28 @@ export function KhkkListPanel({
<FileCheck className="mr-1 inline h-3 w-3" />
{total} kế hoạch Trang {page} / {totalPages}
</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">
<button
type="button"
onClick={() => onPageChange(Math.max(1, 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
type="button"
onClick={() => onPageChange(page + 1)}
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>
</div>
</div>

View File

@ -171,7 +171,7 @@ export function KhkkWorkListPanel({
</div>
)}
{!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.
</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">
{projectName}
</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>
<ul className="divide-y divide-slate-100">
{items.map((pe) => (
@ -241,7 +241,7 @@ export function KhkkWorkListPanel({
</div>
)}
{!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
menu Kế hoạch kết .
</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
đ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 && (
<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 .
</p>
)}

View File

@ -33,7 +33,8 @@
import { useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
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 { Dialog } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
@ -104,6 +105,8 @@ export function KhkkWorkflowPanel({
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
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 steps = plan.workflowSteps ?? []
@ -174,15 +177,18 @@ export function KhkkWorkflowPanel({
const approvalFiles: KhkkAttachmentDto[] =
(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).
const [showChangelog, setShowChangelog] = useState(false)
// [BE-5] Lịch sử thay đổi.
// 🔴 [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({
queryKey: ['khkk-changelogs', plan.id],
queryFn: async () =>
(await api.get<KhkkChangelogDto[]>(`/contract-signing-plans/${plan.id}/changelogs`, {
params: { take: 200 },
})).data,
enabled: showChangelog,
})
const transition = useMutation({
@ -267,41 +273,44 @@ export function KhkkWorkflowPanel({
}
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} ───────────────────────────── */}
<header className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-5 py-3">
<span
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>
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
{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.workflowVersion != null && ` v${plan.workflowVersion}`}
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
</span>
{plan.workflowVersion != null && ` v${String(plan.workflowVersion).padStart(2, '0')}`}
{plan.workflowName && <> · <span className="font-sans">{plan.workflowName}</span></>}
</p>
)}
</header>
</div>
<div className="space-y-4 p-5">
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
{plan.endedByLevelFinalize && (
<div className="rounded border border-violet-300 bg-violet-50 px-3 py-2 text-[11px] text-violet-800">
<div className="font-medium">
Kết thúc tại {plan.finalizeStepName ?? 'cấp được cấu hình'}
{plan.finalizeLevelName ? ` · ${plan.finalizeLevelName}` : ''}
</div>
<div className="mt-0.5">
{isApproved
? 'Kế hoạch đã duyệt xong ngay tại cấp này — không qua CEO / Ban Giám đốc.'
: '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>
</div>
)}
{/* ── 2. Banner kết-thúc-sớm ──────────────────────────────────────────── */}
{/* [S175 · B1] violet → EMERALD khuôn PE (`:360-377`). PE dành violet RIÊNG cho
"⚑ Duyệt thay CEO"; dùng violet cả ở đây là trộn 2 nghĩa vào một màu. */}
{plan.endedByLevelFinalize && (
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
{isApproved ? (
<span>
Kế hoạch đã kết thúc tại{' '}
<span className="font-semibold">{plan.finalizeStepName ?? 'cấp duyệt'}</span>
{plan.finalizeLevelName ? <> · {plan.finalizeLevelName}</> : null} không qua CEO.
</span>
) : (
<span>
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 ───────────────────────────────────────── */}
{steps.length > 0 ? (
@ -317,9 +326,10 @@ export function KhkkWorkflowPanel({
{STATUS_ICON[st]}
</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 && (
<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}
</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'}
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',
)}
>
<Check className="h-3 w-3" /> Duyệt
Duyệt
</button>
<button
type="button"
@ -470,11 +480,11 @@ export function KhkkWorkflowPanel({
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'}
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',
)}
>
<Undo2 className="h-3 w-3" /> Trả lại
Trả lại
</button>
{/* 🧊 [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
@ -488,7 +498,8 @@ export function KhkkWorkflowPanel({
<button
type="button"
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
</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) ── */}
{/* [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 && (
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Paperclip className="h-3.5 w-3.5 text-teal-500" />
File đính kèm khi duyệt
<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">
<div className="border-t border-slate-200 pt-4">
<h3 className="text-sm font-semibold text-slate-900">📎 File đính kèm khi duyệt</h3>
<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>
<div className="mt-2 space-y-1">
{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 className="min-w-0">
<div className="truncate font-medium text-slate-700">{a.fileName}</div>
<div className="text-slate-500">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
</div>
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>{a.fileName}</span>
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
{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
type="button"
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>
</li>
</div>
))}
</ul>
</div>
{previewAtt && (
<AttachmentPreviewDialog
open
basePath="/contract-signing-plans"
evaluationId={plan.id}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
</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
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. */}
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Check className="h-3.5 w-3.5 text-emerald-500" />
Lịch sử duyệt
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
{approvals.length}
</span>
</div>
{/* [S175 · B1] Bỏ pill đếm → `(n)` trong ngoặc · thẻ `bg-slate-50/60 text-[11px]`
→ `bg-white p-3 text-sm` · mốc-giờ ĐẨY PHẢI bằng `justify-between`.
🔴 Badge 2-ngả → 3-NGẢ: trước đây MỌI thứ không phải `Reject` đều tô emerald
⇒ "Trả lại" (amber ở PE) bị vẽ xanh như đã-duyệt — đọc ngược nghĩa.
`toPhase` đã có sẵn (`khkk.ts:307`) nên phân được TraLai ⟂ TuChoi ⟂ Duyệt. */}
<div className="border-t border-slate-200 pt-4">
<h3 className="text-sm font-semibold text-slate-900">Lịch sử duyệt ({approvals.length})</h3>
{approvals.length === 0 ? (
<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">
{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]">
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
<span className={cn(
'rounded px-1.5 py-0.5 text-[9px] font-semibold',
a.decision === KhkkApprovalDecision.Reject ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800',
)}>
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
</span>
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{formatDateTime(a.approvedAt)}</span>
</div>
<div className="mt-0.5 text-slate-600">
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
{a.levelName ? ` (${a.levelName})` : ''}
<span className="text-slate-400">
{' '}· {KHKK_PHASE_LABELS[a.fromPhase]} {KHKK_PHASE_LABELS[a.toPhase]}
</span>
</div>
{a.comment?.trim() && (
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">{a.comment}</div>
)}
</li>
))}
<ul className="mt-2 space-y-1.5">
{approvals.map(a => {
const isReject = a.toPhase === KhkkPhase.TuChoi
const isReturn = a.toPhase === KhkkPhase.TraLai
return (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<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">
<span className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-semibold',
isReject ? 'bg-rose-100 text-rose-800'
: isReturn ? 'bg-amber-100 text-amber-800'
: 'bg-emerald-100 text-emerald-800',
)}>
{DECISION_LABEL[a.decision] ?? 'Chuyển trạng thái'}
</span>
<span className="font-medium">{a.approvedByFullName ?? '(hệ thống)'}</span>
</div>
<span className="text-[11px] text-slate-500">{formatDateTime(a.approvedAt)}</span>
</div>
<div className="mt-1 text-[12px] text-slate-600">
{a.stepName ? `Bước ${a.stepOrder}${a.stepName}` : 'Cấp duyệt'}
{a.levelOrder != null && ` · Cấp ${a.levelOrder}`}
{a.levelName ? ` (${a.levelName})` : ''}
<span className="text-slate-400">
{' '}· {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>
)}
</div>
{/* ── 7. Lịch sử thay đổi ────────────────────────────────────────────── */}
<div>
<button
type="button"
onClick={() => setShowChangelog(v => !v)}
className="flex w-full items-center gap-1.5 text-xs font-semibold text-slate-700 hover:text-brand-700"
>
{/* [S175 · B1] BỎ GẬP — PE render thẳng (`PeDetailTabs.tsx:787-794`). Nội-dung
đã 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ì. */}
<div className="border-t border-slate-200 pt-4">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-900">
<History className="h-3.5 w-3.5 text-slate-400" />
Lịch sử thay đi
<span className="ml-auto text-[10px] font-normal text-slate-400">{showChangelog ? 'Ẩn' : 'Xem'}</span>
</button>
{showChangelog && (
<div className="mt-1.5">
</h3>
<div className="mt-2">
{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.data && changelogs.data.length === 0 && (
@ -614,10 +647,8 @@ export function KhkkWorkflowPanel({
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
{action !== null && (
<Dialog
@ -625,7 +656,7 @@ export function KhkkWorkflowPanel({
onClose={() => setAction(null)}
title={dialogTitle}
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}>
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
</Button>
@ -663,22 +694,26 @@ export function KhkkWorkflowPanel({
</label>
</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>
Ý 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>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
rows={3}
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
`POST {id}/approval-attachments` (BE ép `purpose=ApprovalAttachment`). */}
{action !== KhkkTransitionAction.Submit && (
<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">
<input
ref={fileInputRef}
@ -714,7 +749,7 @@ export function KhkkWorkflowPanel({
{action === KhkkTransitionAction.Approve && (
<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 đè ô ).
</p>
)}
@ -727,7 +762,7 @@ export function KhkkWorkflowPanel({
onClose={() => setConfirmDelete(false)}
title="🗑 Xóa kế hoạch ký kết"
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}>
{removePlan.isPending ? 'Đang xóa...' : 'Xóa phiếu'}
</Button>
@ -739,6 +774,6 @@ export function KhkkWorkflowPanel({
</div>
</Dialog>
)}
</section>
</div>
)
}

View File

@ -15,10 +15,15 @@ export function isPreviewable(fileName: string): boolean {
type Props = {
open: boolean
/** Id của bản-ghi CHỦ (phiếu PE, hoặc kế-hoạch KHKK khi truyền `basePath`). */
evaluationId: string
attachmentId: string
fileName: string
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).
@ -29,6 +34,7 @@ type Props = {
* cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */
export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose,
basePath = '/purchase-evaluations',
}: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
@ -58,7 +64,7 @@ export function AttachmentPreviewDialog({
setLoading(true)
setError(null)
api.get(`/purchase-evaluations/${evaluationId}/attachments/${attachmentId}/view`, {
api.get(`${basePath}/${evaluationId}/attachments/${attachmentId}/view`, {
responseType: 'blob',
})
.then(res => {
@ -78,7 +84,7 @@ export function AttachmentPreviewDialog({
if (currentUrl) window.URL.revokeObjectURL(currentUrl)
setBlobUrl(null)
}
}, [open, evaluationId, attachmentId])
}, [open, evaluationId, attachmentId, basePath])
const ready = blobUrl && !loading && !error

View File

@ -89,6 +89,7 @@ export function PipelineTreePanel({
bodyMaxHClass,
stage2Options,
hideHeader,
hideSearch,
}: {
currentStage: PipelineStageNo
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
// `<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
// [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 [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. */}
<div className={cn('min-h-0 flex-col', hideHeader || mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
<div className="relative border-b border-slate-200 p-2">
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Lọc dự án / hạng mục / mã phiếu…"
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>
{!hideSearch && (
<div className="relative border-b border-slate-200 p-2">
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Lọc dự án / hạng mục / mã phiếu…"
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 className={cn('overflow-y-auto', bodyMaxHClass ?? 'max-h-[28rem] lg:max-h-[calc(100vh-16rem)]')}>
{!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
// 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]`;
// · Panel 1 = danh sách (`KhkkWorkListPanel`) + nút hành-động dính đáy;
// · header 1 dòng + grid `lg:grid-cols-[260px_1fr] xl:grid-cols-[320px_1fr]` — ĐÚNG BYTE
// 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 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*).