Compare commits
4 Commits
a0ccac3982
...
b811e1125d
| Author | SHA1 | Date | |
|---|---|---|---|
| b811e1125d | |||
| bd309fa600 | |||
| f3a0d0cca5 | |||
| 2855326767 |
@ -11,9 +11,13 @@ chain:
|
||||
- [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)
|
||||
- [x] đợt B ship `43e1bd7`: B1 panel duyệt (gỡ card-accent CLASS · emerald · glyph · Eye · badge 3-ngả · bỏ gập +GỠ `enabled` kẻo query câm) · B2 6 P0 (table-fixed 880 · orDash · Lock) · B3 (đầu cột · `hideSearch` gộp 2 ô tìm · 3 nấc · WCAG slate-500 ×7 · icon phân trang · chip baseline) · B4 (2 comment sai) — mirror 7/7 sha256, build XANH ×2
|
||||
- [x] deploy `43e1bd7` VERIFIED 2-vế ×2 app: hash ĐỔI (admin `Dlhi3eF1`→`O5Z5H2FK` · eoffice `B9PVjJnU`→`CD72NWts`) ∧ chuỗi MỚI `Ghi chú của bạn`=1/1 · `Cây toàn trình`=3/3 ∧ chuỗi CŨ `Ý kiến của bạn`=0/0; control-dương `Tải xuống`=10/10 (title= giữ được) · `Ý kiến cấp duyệt`=6/6 (CỐ Ý không đổi)
|
||||
- [ ] 🔴 CHƯA soi mắt prod 375/1366/1440 (5 lane NO-SCREENSHOT, gotcha #3) — mọi kết luận đợt B là ĐỌC MÃ
|
||||
- [ ] 🅿️ PARK (anh chưa giao làm — chỉ ĐO để biết trạng-thái): đường **KHKK → lũy kế NGÂN SÁCH chưa tồn tại**. `PeBudgetAccumulator.cs` (140 dòng) chỉ đọc 3 bảng PE (`PurchaseEvaluations` · `…Suppliers` · `…Quotes`); `grep -c 'ContractSigningPlan'` = **0** — control-dương: chuỗi đó có ở **7 file** khác trong Application layer ∧ `PurchaseEvaluations` = 6 hit trong chính file này ⇒ grep sống, 0-hit là THẬT. Trường "điều chỉnh tăng tiền" anh nói = `AdjustmentAmount` / `ProAdjustmentAmount` (*NS V0/hiệu chỉnh tăng giảm*, cho phép ÂM) per cặp Dự án × Hạng mục, sửa qua `PUT /purchase-evaluations/{id}/budget/{pro|ccm}`
|
||||
- [x] đợt D + C3 ship `a0ccac3` + deploy VERIFIED 2-vế ×2 app (admin `O5Z5H2FK`→`BTWwiQTV` · eoffice `CD72NWts`→`Bn8xEtmU`; 5 chuỗi mới =1/1, control-dương cây =1/1): O6 `onOpenPe` + xem PE tại chỗ (`?pe=`, loại-trừ `?id=`) · O4 gửi `dossierItemId` (rail BE có sẵn, FE chưa từng gửi) · O7 bút chì + chế-độ sửa · O8 cây 2 cấp · C3 value slate-900 · O3 **vế (2)** 2 tổng toàn phiếu
|
||||
- [x] 🔴 **ANH CHỐT @S175 — O3 vế (1) GIỮ 1-1, KHÔNG LÀM**: *"Cái này ko theo tiền, hiện cho 1-1 trước đã"* (anh nói 2 lần, mở+đóng tin nhắn). ⇒ **KHÔNG viết route POST/DELETE line**, KHÔNG đổi dialog cầu HĐ sang checkbox. Đúng khuôn **máy rộng / UI hẹp** anh chốt S164: schema để rộng sẵn (unique 3 cột Mig 71), UI giữ ca hẹp. 🔸 Cơ-chế cho ca "nhiều hơn 1" anh chỉ: **KHÔNG tách dòng mà ĐIỀU CHỈNH TĂNG TIỀN**, đi qua mục NGÂN SÁCH — *"bên mục ngân sách có mục dự án chưa điền vào; nếu tăng tiền lũy kế thì phía KHKK sẽ điền vào đấy"*
|
||||
- [x] 🔴 ANH CHỐT @S175 cách đọc "sum tổng" O3 (gate TIỀN đã mở): **① trục = CHỈ TỔNG TOÀN PHIẾU** (KHÔNG có dòng tổng per-NCC) · **② hiện 2 TỔNG RIÊNG** cạnh nhau (Σ giá đề xuất ⟂ Σ giá duyệt), đặt PHÍA TRÊN bảng
|
||||
- [ ] 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
|
||||
@ -21,10 +25,12 @@ chain:
|
||||
- [ ] 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
|
||||
|
||||
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
|
||||
next: 🔴 HỎI ANH cách chia tiền N-hạng-mục (chặn O3 vế 1) + anh soi mắt prod 375/1366/1440 → [đuôi, đừng rơi] thăng luật E4 ➊ · synthesis S173 · 5 điểm governance · kéo 2 thư + adap-report
|
||||
|
||||
verify:
|
||||
- `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)
|
||||
- `curl -s https://admin.solutions.com.vn/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js'` → khác `index-BTWwiQTV.js` = đợt SAU đã tới (mốc @S175 sau đợt D)
|
||||
- 🔴 LUẬT ĐO bundle prod: `grep -o '<chuỗi>' | wc -l`, **KHÔNG** `grep -c` — bundle minify là 1 mega-dòng nên `-c` trả 0/1 chứ không phải số lần (cắn thật @S175)
|
||||
- `grep -cE 'className=.*card-accent' fe-admin/src/components/khkk/KhkkWorkflowPanel.tsx` → 0 = panel TRẦN. 🔴 đo `className=.*`, KHÔNG đo token trần: comment giải-thích có nhắc tên class (use ⟂ mention)
|
||||
- `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
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
# run — cột DỰ ÁN trong bảng ngân sách (S175, 2026-08-05)
|
||||
|
||||
**Engine:** `/fable-real` (LỆNH-A, per-invocation H21) — **1 vai × SINGLE top-model deep-pass**.
|
||||
**Vai anh gán:** `investigator-codebase` · **Đề-bài anh gõ:** *"invest case cột DỰ ÁN trong bảng ngân sách → sau đó điều chỉnh lại"*
|
||||
|
||||
> 🔴 Engine **propose-only**. Spec-file 3-mục do **LEAD ghi SAU khi verify** (honest-note (c)).
|
||||
|
||||
---
|
||||
|
||||
## Bối cảnh (lead nạp — anh nói gì)
|
||||
|
||||
Ảnh anh gửi ×2: bảng **B. THỰC HIỆN** trong phiếu **Duyệt NCC**, khoanh đỏ **TOÀN BỘ cột DỰ ÁN** (ảnh-2) —
|
||||
cột đó đang `—` ở mọi dòng per-cột, trong khi PRO và CCM đều có số.
|
||||
|
||||
Lời anh (verbatim, 2 lượt trả lời AskUser):
|
||||
- *"Không nó có 1 mục riêng 3 thằng này là 3 thằng riêng, hiện dự án chưa có thì khi chọn hạng mục → nó cộng vào mục đó"*
|
||||
- *"Không phải ý là nó đã có sẵn cái bảng ngân sách ở giao diện NCC → Còn thiếu của Dự án → Giờ chọn hạng mục điền tiền sau đó nó sẽ cộng thêm vào mục đó"*
|
||||
- Trước đó: *"nếu nhiều hơn thì điều chỉnh tăng tiền, bên mục ngân sách có mục dự án chưa điền vào. Nếu tăng tiền lũy kế thì phía KHKK sẽ điền vào đấy"*
|
||||
|
||||
🔴 **ANH GỠ NÚT THẮT (tin nhắn thứ 3, giữa lượt — ĐÂY LÀ CÂU CHỐT, đọc kỹ trước mọi thứ khác):**
|
||||
> *"phần trên duyệt NCC → ngân sách bảng đó mang xuống → KHKK → nhập từng hạng mục sau đó cộng vào ô của dự án"*
|
||||
|
||||
⇒ **Luồng đích 4 nhịp:** ① bảng ngân sách ĐANG SỐNG ở Duyệt NCC (GĐ1) → ② **MANG XUỐNG** KHKK (GĐ2)
|
||||
→ ③ ở KHKK **nhập tiền THEO TỪNG HẠNG MỤC** → ④ các dòng đó **CỘNG vào ô DỰ ÁN**.
|
||||
|
||||
🔑 **Câu này hoà-giải mâu-thuẫn tưởng-như-vô-lý ở 2 lượt trước.** "3 thằng RIÊNG" **không** có nghĩa DỰ ÁN
|
||||
là ô nhập tay cấp dự án — mà nghĩa là **NGUỒN của nó khác hẳn**: PRO/CCM là 2 vai nhập cho *gói thầu này*,
|
||||
còn DỰ ÁN được nuôi bằng **các dòng hạng mục nhập BÊN KHKK**. Vì nguồn khác nên nó là "thằng riêng";
|
||||
vì là cộng-dồn nên "chọn hạng mục điền tiền → cộng vào mục đó". **Cả hai vế đều đúng cùng lúc.**
|
||||
Khớp luôn câu anh nói lượt trước: *"Nếu tăng tiền lũy kế thì phía KHKK sẽ điền vào đấy."*
|
||||
|
||||
🔴 **Hệ-quả cho vai:** câu hỏi KHÔNG còn là "A tự-cộng hay B nhập-tay". Nó là:
|
||||
**bảng ngân sách MANG XUỐNG KHKK như thế nào, ai được nhập, nhập ở đâu, và đường cộng ngược lên ô DỰ ÁN
|
||||
đi qua bảng/khoá nào** — trong khi `PeBudgetAccumulator` hiện **0 hit** `ContractSigningPlan` (đã đo).
|
||||
|
||||
## Đo sơ-bộ của lead (để vai khỏi lặp — VERIFY LẠI, đừng tin suông)
|
||||
|
||||
| Đã đo | Kết quả | Nguồn |
|
||||
|---|---|---|
|
||||
| DTO `PeBudgetSummary` có trường cấp DỰ ÁN? | **KHÔNG** — mọi trường khoá theo cặp (Dự án × Hạng mục) | `fe-admin/src/types/purchaseEvaluation.ts:312-339` |
|
||||
| Comment ý-định lúc dựng cột | ghi thẳng `DỰ ÁN "—"` | `PeDetailTabs.tsx:1503` |
|
||||
| Comment DTO | *"DỰ ÁN = FE hiển thị-only (chưa wire BE)"* | `purchaseEvaluation.ts:311` |
|
||||
| Cột DỰ ÁN hardcode `—` ở đâu | ≥3 chỗ | `PeDetailTabs.tsx:1535 · 1664 · 1677` |
|
||||
| `PeBudgetAccumulator` gom từ bảng nào | CHỈ 3 bảng PE; `grep -c 'ContractSigningPlan'` = **0** (control-dương: chuỗi đó có ở 7 file khác Application layer) | `PeBudgetAccumulator.cs` |
|
||||
| Entity ngân sách | `PeWorkItemBudget` = 1 record / cặp `ProjectId` × `WorkItemId`; có `ProInitial/ProAdjustment` + `Initial/Adjustment` | `Domain/PurchaseEvaluations/PeWorkItemBudget.cs:27-36` |
|
||||
|
||||
🔴 **MANH MỐI LỚN NHẤT — vai PHẢI đào:** module **Budget (Ngân sách dự án) CŨ đã bị XÓA HẲN ở Mig 50 (S61)**
|
||||
(CLAUDE.md: *"XÓA module Budget cũ, thay bằng `PeWorkItemBudgets` ngân sách per-gói-thầu"*; FE `Bg_*` gỡ hết).
|
||||
⇒ Giả-thuyết cần chứng/bác: **cột DỰ ÁN là DI-CHỨNG của module cũ** — chỗ ngồi còn, ruột bị rút.
|
||||
Nếu đúng, "điều chỉnh lại" của anh = **dựng lại tầng ngân sách cấp DỰ ÁN**, không phải vá 1 cột FE.
|
||||
|
||||
## taskList snapshot
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"role": "investigator-codebase",
|
||||
"tier": "fable",
|
||||
"task": "Điều tra TRỌN luồng 4 nhịp anh chốt: bảng ngân sách ở Duyệt NCC → MANG XUỐNG KHKK → nhập tiền TỪNG HẠNG MỤC ở KHKK → CỘNG vào ô DỰ ÁN. Cụ thể: (1) truy lịch-sử vì sao cột DỰ ÁN tồn tại mà rỗng (Mig 50 xóa module Budget cũ · Mig 56/59 dựng ma-trận 3 cột) — DI-CHỨNG hay CHỖ-CHỪA-SẴN; (2) đo tàn-dư schema/DTO/FE của ngân sách cấp DỰ ÁN còn sót; (3) dựng ĐƯỜNG DỮ-LIỆU đích: KHKK nhập per-hạng-mục thì GHI VÀO ĐÂU (tái dùng PeWorkItemBudgets khoá ProjectId×WorkItemId? hay bảng mới?), ai được nhập (role/phase gate), và ô DỰ ÁN CỘNG NGƯỢC LÊN bằng query nào — nhớ PeBudgetAccumulator hiện 0 hit ContractSigningPlan; (4) chốt 1 phương-án kèm GIÁ (số migration · số endpoint · số file FE), so với ít nhất 1 phương-án thay-thế đã cân-nhắc rồi BÁC; (5) chỉ ĐÍCH DANH mọi file:line phải sửa; (6) nêu rõ mọi rào TIỀN có thể vỡ (freeze snapshot phiếu DaDuyet S133 · lũy kế tạm-tính S134 · choke-point ApplyApprovedValuesOnFinalize) mà thay đổi này có thể đụng."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Stages
|
||||
|
||||
- [x] P1 — lead scaffold run-folder + taskList snapshot
|
||||
- [ ] P2 — engine deep-pass (propose-only) → return
|
||||
- [ ] P3 — lead VERIFY return + ghi `spec-*.md` 3-mục
|
||||
- [ ] P4 — thực-thi theo spec ("sau đó điều chỉnh lại")
|
||||
|
||||
## wf run-id
|
||||
|
||||
`wf_a574cff1-6b9` — 1 agent · `investigator-codebase` · tier `fable` · propose-only.
|
||||
|
||||
🔸 **Lần phóng ĐẦU FAIL SẠCH (0 agent · 0 token · 22ms):** guard `checkpointApproved chưa set`
|
||||
(anti-accidental-fire) chặn ngay cửa. Đúng thiết-kế — em-main phải BÁO {số agent · vai · task}
|
||||
rồi mới set cờ. Đã báo, set `checkpointApproved:true`, phóng lại cùng `resumeFromRunId`.
|
||||
**Ghi lại làm vết: guard này CÓ RĂNG, không phải trang-trí.**
|
||||
@ -0,0 +1,86 @@
|
||||
# SPEC — Thêm N hạng mục (giá + ghi chú) & cột DỰ ÁN trong ma trận ngân sách (05-08-2026)
|
||||
|
||||
> Nguồn: `/fable-real investigator-codebase` single deep-pass (`wf_a574cff1-6b9`, 1 lane, 169K tok, 38 tool-use, ~12′).
|
||||
> Engine **propose-only**; file này **LEAD ghi SAU khi verify** (H21 honest-note (c)).
|
||||
|
||||
---
|
||||
|
||||
## ① TÍNH-NĂNG / ĐỀ-BÀI
|
||||
|
||||
**Anh chốt (verbatim, tin nhắn cuối):** *"Chỉ thêm hạng mục → nhiều hạng mục → giá và ghi chú, vậy thôi"*
|
||||
(kèm ảnh khoanh đỏ mục **2. Giá đề xuất theo NCC trúng thầu**, mũi tên chỉ cột **HẠNG MỤC**).
|
||||
|
||||
⇒ **Phạm vi lượt này = A.** Mục B ghi lại để không rơi, **chưa land**.
|
||||
|
||||
**A. Dòng hạng mục (LÀM NGAY):** 1 NCC được **N dòng hạng mục**, mỗi dòng **nhập tay giá + ghi chú**.
|
||||
🔑 Câu này **mở khoá bế tắc trước đó**: lead từng dừng vì hỏi *"tách N hạng mục thì CHIA tiền NCC thế nào"*.
|
||||
Câu trả lời là **không có luật chia — người nhập tay từng dòng**. Hết chặn.
|
||||
|
||||
**B. Cột DỰ ÁN (CHƯA LÀM — chờ anh chốt 3 câu ở §③):** 11 ô `—` trong ma trận ngân sách của phiếu Duyệt NCC,
|
||||
nuôi bằng Σ các dòng hạng mục bên KHKK.
|
||||
|
||||
### 🔴 3 đính-chính engine bắt được — lead ghi lại vì 2 cái đầu là SAI CỦA LEAD
|
||||
|
||||
| # | Lead nói | Sự thật đo được | Nguồn |
|
||||
|---|---|---|---|
|
||||
| a | *"cột DỰ ÁN là DI-CHỨNG module Budget cũ bị Mig 50 rút ruột"* | **SAI.** Cột sinh ở `e33481e` **19-06** (S76/Mig 56) — **SAU** Mig 50 (13-06) **6 ngày**, và **cùng commit** với comment ý-định *"DỰ ÁN: hiển thị FE-only (chưa wire BE — sau mới có NGƯỜI DỰ ÁN NHẬP)"* ⇒ **CHỖ-CHỪA-SẴN có chủ đích**, không phải xác chết | `PeWorkItemBudget.cs:19` + git `e33481e` |
|
||||
| b | *"cột DỰ ÁN hardcode `—` ở ≥3 chỗ"* | **THIẾU.** Thật = **11 ô/app**: Block A `PeDetailTabs.tsx:1415·1426·1447` + Block B `:1535·1589·1599·1619·1629·1639·1664·1677`; fe-user mirror y hệt | pattern-count 19/19 cả 2 app |
|
||||
| c | *(lead không biết)* | 🔑 **Tầng NS cấp dự-án ĐÃ CÓ MỘT NỬA**: `Project.BudgetTotal` sinh từ **Mig 2 — day-1**, wired TRỌN 2 đầu (BE CRUD `ProjectFeatures.cs:18·82·107·129·154` + FE cột **"Ngân sách"** `ProjectsPage.tsx:117` + form `:205`). Chỉ chưa nối vào ma trận PE, và **DỮ-LIỆU RỖNG: 61/69 dự án NULL**, 8 cái có số đều là demo-seed gated-OFF-prod | `Domain/Master/Project.cs:12` · `DbInitializer.cs:3212-3219` |
|
||||
|
||||
🔴 **(c) rất có thể CHÍNH LÀ thứ anh nói** — *"bên mục ngân sách có mục dự án chưa điền vào"*.
|
||||
Ô đó **có thật, nhập được, và đang trống** ở 61/69 dự án. Đây là lớp **"cơ-chế đúng, dữ-liệu không có"** lần thứ 7.
|
||||
|
||||
🔸 Kết-luận hệ-quả của lead (*"phải dựng một tầng, không phải vá 1 cột FE"*) **vẫn đứng** — chỉ tiền-đề đổ.
|
||||
|
||||
---
|
||||
|
||||
## ② CÁCH IMPLEMENT
|
||||
|
||||
### A — Dòng hạng mục N-dòng + giá + ghi chú (lượt này)
|
||||
|
||||
**BE — 0 migration** (unique `(PlanId, SupplierId, CatalogEntryId)` lọc `IsDeleted=0` đã có từ Mig 71):
|
||||
|
||||
| # | Việc | Ghi chú |
|
||||
|---|---|---|
|
||||
| A1 | `UpdateContractSigningPlanLineCommand` +`ProposedAmount?` +`Note?`, đổi `CatalogEntryId` → **nullable** | Null-safe khuôn `UpdateDraftCommand:559-563`: `null` = không gửi ⇒ giữ nguyên. Nullable ⇒ **call-site FE cũ không phải sửa dòng nào** |
|
||||
| A2 | `AddContractSigningPlanLineCommand(PlanId, SupplierId, CatalogEntryId, ProposedAmount, Note)` | 🔴 `SupplierId` **PHẢI ∈ NCC đã có trên phiếu** — NCC đến từ winner của PE, không được đẻ NCC mới ở đây. 🔴 `CatalogEntryId` **BẮT BUỘC**: unique index **cố ý không lọc NULL** ⇒ thêm dòng NULL sẽ đụng dòng gốc |
|
||||
| A3 | `DeleteContractSigningPlanLineCommand(PlanId, LineId)` | 🔴 **CHỈ xoá được dòng THÊM TAY** (`PeReferenceAmount == 0`). Dòng gốc sinh từ PE mang snapshot tham chiếu — xoá là mất số gốc |
|
||||
| A4 | 3 route: `POST {id}/lines` · `DELETE {id}/lines/{lineId}` · mở rộng body `PUT {id}/lines/{lineId}` | policy `KeHoachKyKet.Update` (gotcha #85: đúng key endpoint) |
|
||||
|
||||
🔴 **`PeReferenceAmount` của dòng THÊM TAY = 0, KHÔNG copy số NCC.**
|
||||
Tham chiếu là Σ báo giá được chọn của **cả NCC**, đã nằm trọn ở dòng gốc; lặp lại là **đếm đôi tiền dự án**.
|
||||
|
||||
**FE (mirror 2 app):** mục 2 thêm nút `+ Thêm hạng mục` theo NCC · ô nhập **giá đề xuất** + **ghi chú** inline
|
||||
(chỉ phase Nháp/Trả lại) · nút xoá cho dòng thêm tay.
|
||||
|
||||
### B — Cột DỰ ÁN (CHƯA LÀM)
|
||||
|
||||
Phương án engine chốt = **live-derive, 0 migration, 0 endpoint mới**: JOIN `Lines → Plan WHERE Plan.ProjectId = pe.ProjectId`,
|
||||
2 tầng mirror S134 (CHÍNH-XÁC = Σ `ApprovedAmount` plan `DaDuyet` ⟂ TẠM-TÍNH = Σ `ProposedAmount` plan đang mở).
|
||||
Đã BÁC: ledger-table riêng (+1 mig, số derive được lại đi lưu) · cột `DuAn*` trên `PeWorkItemBudgets`
|
||||
(**sai trục khoá** — bảng per-`WorkItemId` mà chứa số per-`Project`; và `CatalogEntryId` SP-002 **không map được** `WorkItemId`).
|
||||
|
||||
🔴 **Rào tiền phải xử khi land B:** nhánh **frozen** `PurchaseEvaluationFeatures.cs:946-959` dựng DTO **bằng tay** —
|
||||
quên field mới thì phiếu `DaDuyet` hiện DỰ ÁN = 0 **câm lặng** (đúng lớp *"vắng mặt trông giống ổn"*).
|
||||
|
||||
---
|
||||
|
||||
## ③ CHECKLIST — acceptance ĐO ĐƯỢC
|
||||
|
||||
| # | Deliverable | Acceptance |
|
||||
|---|---|---|
|
||||
| 1 | A1 land | `grep -c 'ProposedAmount' ContractSigningPlanFeatures.cs` tăng; call-site FE cũ (`catalogEntryId` đơn lẻ) vẫn build |
|
||||
| 2 | A2 rào NCC | POST với `supplierId` KHÔNG thuộc phiếu → **409**, message nêu rõ |
|
||||
| 3 | A2 rào trùng | POST trùng cặp (Plan, Supplier, CatalogEntry) → **409** chứ không phải 500 unique-violation |
|
||||
| 4 | A3 rào dòng gốc | DELETE dòng có `PeReferenceAmount != 0` → **409** |
|
||||
| 5 | Tiền không đếm đôi | Dòng thêm tay có `PeReferenceAmount == 0` — verify bằng đọc DB/DTO sau khi tạo |
|
||||
| 6 | Mirror | `sha256sum` từng cặp file FE 2 app — **khớp 100%** |
|
||||
| 7 | Build | `npm run build` ×2 app + `dotnet build` — 0 error. 🔴 Đo **NỘI DUNG** bundle, đừng tin thời-gian build |
|
||||
| 8 | Deploy | bundle prod 2 vế: chuỗi mới **có** ∧ chuỗi cũ **vắng**, kèm control-dương. Dùng `grep -o \| wc -l`, KHÔNG `grep -c` (bundle 1 mega-dòng) |
|
||||
| 9 | 🔴 Mắt anh | Soi thật **375 / 1366 / 1440** trên prod — mọi kết luận giao diện tới giờ là ĐỌC MÃ |
|
||||
|
||||
### 🔴 3 câu treo — cần anh chốt TRƯỚC khi land mục B
|
||||
|
||||
1. **Σ KHKK đổ vào Ô NÀO** trong 11 ô? (engine đề xuất: hàng **5 – lũy kế** Block B, 2 tầng chính-xác + tạm-tính khuôn amber S134; còn hàng **NS full** Block A đọc `Project.BudgetTotal`)
|
||||
2. **Phiếu đã duyệt**: cột DỰ ÁN **live** hay **đóng băng** theo snapshot?
|
||||
3. Sửa thẳng `ProposedAmount` (delta tự-audit = `ProposedAmount − PeReferenceAmount`) là đủ, hay cần field **"điều chỉnh"** riêng?
|
||||
@ -18,11 +18,14 @@
|
||||
// Không dựng workspace riêng cho việc này.
|
||||
// [R-8] KHKK KHÔNG có phiếu V1-legacy (module sinh 2026-07-29) ⇒ KHÔNG port nhánh fallback
|
||||
// V1 của PE (`PeDetailTabs.tsx:434-436`) — đó là code chết ngay ngày đầu.
|
||||
import { Fragment, useState, type ReactNode } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
||||
ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
||||
} from 'lucide-react'
|
||||
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
|
||||
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
|
||||
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
|
||||
import { toast } from 'sonner'
|
||||
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@ -158,6 +161,13 @@ export function KhkkDetailContent({
|
||||
const [ghiChu, setGhiChu] = useState('')
|
||||
const [hoSoLink, setHoSoLink] = useState('')
|
||||
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
|
||||
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
|
||||
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
|
||||
// [S175] NCC đang mở form "thêm hạng mục" (null = không mở form nào).
|
||||
const [addFor, setAddFor] = useState<{ supplierId: string; supplierName: string | null } | null>(null)
|
||||
const [addCatalogId, setAddCatalogId] = useState('')
|
||||
const [addAmount, setAddAmount] = useState('')
|
||||
const [addNote, setAddNote] = useState('')
|
||||
|
||||
const phase = plan.phase as KhkkPhaseValue
|
||||
const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
||||
@ -198,6 +208,32 @@ export function KhkkDetailContent({
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// [S175 — anh chốt "nhiều hạng mục → giá và ghi chú"] 3 mutation cho DÒNG hạng mục.
|
||||
// 🔴 Body null-safe: chỉ gửi field ĐANG sửa; field không gửi thì BE giữ nguyên
|
||||
// (`UpdateContractSigningPlanLineCommand` — khuôn bug-class #73/S42).
|
||||
const saveLineField = useMutation({
|
||||
mutationFn: async (p: { lineId: string; proposedAmount?: number; note?: string }) =>
|
||||
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, {
|
||||
proposedAmount: p.proposedAmount,
|
||||
note: p.note,
|
||||
}),
|
||||
onSuccess: () => { toast.success('Đã lưu dòng'); invalidate() },
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const addLine = useMutation({
|
||||
mutationFn: async (p: { supplierId: string; catalogEntryId: string; proposedAmount: number; note?: string }) =>
|
||||
api.post(`/contract-signing-plans/${id}/lines`, p),
|
||||
onSuccess: () => { toast.success('Đã thêm hạng mục'); setAddFor(null); invalidate() },
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const deleteLine = useMutation({
|
||||
mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`),
|
||||
onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() },
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const upsertItem = useMutation({
|
||||
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
||||
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
||||
@ -293,6 +329,25 @@ export function KhkkDetailContent({
|
||||
}
|
||||
}
|
||||
|
||||
// [S175 · anh chốt] GỘP mục 3 (Căn cứ hồ sơ) + mục 4 (File đính kèm) thành MỘT khối
|
||||
// "File đính kèm" khuôn Duyệt NCC (`PeDetailTabs.tsx:3382-3418`): 1 danh sách PHẲNG,
|
||||
// mỗi dòng = 1 file có badge cho biết nó thuộc căn cứ nào.
|
||||
// 🔴 CHỈ gộp phần HIỂN THỊ — máy căn cứ hồ sơ GIỮ NGUYÊN (entity `KhkkDossierItem`,
|
||||
// 3 endpoint POST/PUT/DELETE dossier-items, changelog). Xoá máy đi để "cho gọn giao diện"
|
||||
// là mất `kind`/`status`/TVGS — những thứ BE đang ghi và người duyệt đang đọc.
|
||||
// Căn cứ ĐÃ KHAI mà CHƯA có file vẫn phải hiện 1 dòng, nếu không thì khai xong nó biến mất
|
||||
// và người dùng tưởng bấm hụt.
|
||||
type MergedRow =
|
||||
| { rk: 'file'; att: KhkkAttachmentDto; item: KhkkDossierItemDto | null }
|
||||
| { rk: 'empty'; item: KhkkDossierItemDto }
|
||||
const mergedRows: MergedRow[] = []
|
||||
for (const d of plan.dossierItems) {
|
||||
const files = dossierAttachments.get(d.id) ?? []
|
||||
if (files.length === 0) mergedRows.push({ rk: 'empty', item: d })
|
||||
else for (const a of files) mergedRows.push({ rk: 'file', att: a, item: d })
|
||||
}
|
||||
for (const a of planAttachments) mergedRows.push({ rk: 'file', att: a, item: null })
|
||||
|
||||
// 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
|
||||
// này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
|
||||
// là kể một chuyện hai lần trên cùng màn hình. Dữ-liệu `plan.levelOpinions` /
|
||||
@ -525,213 +580,289 @@ export function KhkkDetailContent({
|
||||
</>
|
||||
) : orDash(l.tenHangMuc)}
|
||||
</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>
|
||||
{/* Dòng THÊM TAY có `peReferenceAmount = 0` — BE cố ý KHÔNG copy số tham
|
||||
chiếu của NCC (đã nằm trọn ở dòng gốc), lặp lại là đếm đôi. Hiện
|
||||
em-dash mờ thay vì "0 đ" để không đọc nhầm thành "tham chiếu bằng 0". */}
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">
|
||||
{l.peReferenceAmount === 0 ? DASH : formatVnd(l.peReferenceAmount)}
|
||||
</td>
|
||||
{/* [S175] GIÁ ĐỀ XUẤT nhập tay — anh chốt "nhiều hạng mục → giá và ghi chú".
|
||||
Lưu khi rời ô (onBlur) và chỉ khi SỐ ĐỔI THẬT, tránh bắn 1 request mỗi
|
||||
lần click vào rồi click ra. */}
|
||||
<td className="px-3 py-2 text-right tabular-nums text-brand-800">
|
||||
{isDraft && !l.contractId ? (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
defaultValue={l.proposedAmount}
|
||||
disabled={saveLineField.isPending}
|
||||
aria-label={`Giá đề xuất dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
|
||||
onBlur={(e) => {
|
||||
const v = Number(e.target.value)
|
||||
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
|
||||
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
|
||||
}}
|
||||
className="h-8 w-full min-w-[7rem] rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||
/>
|
||||
) : 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>
|
||||
<td className="px-3 py-2 text-slate-600">
|
||||
{isDraft && !l.contractId ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
defaultValue={l.note ?? ''}
|
||||
placeholder="Ghi chú…"
|
||||
disabled={saveLineField.isPending}
|
||||
aria-label={`Ghi chú dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim()
|
||||
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
|
||||
}}
|
||||
className="h-8 w-full min-w-[6rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||
/>
|
||||
{/* Chỉ dòng THÊM TAY mới xoá được. Dòng gốc sinh từ phiếu Duyệt NCC
|
||||
mang snapshot tham chiếu — BE cũng chặn, đây chỉ là không mời bấm. */}
|
||||
{l.peReferenceAmount === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
|
||||
disabled={deleteLine.isPending}
|
||||
title="Xoá hạng mục này"
|
||||
className="shrink-0 rounded px-1 py-1 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : orDash(l.note)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* [S175 — anh chốt] THÊM HẠNG MỤC cho 1 NCC. 1 NCC được N hạng mục: máy đã cho
|
||||
phép từ Mig 71 (unique 3 cột `Plan × Supplier × CatalogEntry`), lượt này chỉ
|
||||
bung UI — đúng khuôn "máy rộng, UI hẹp" anh chốt S164.
|
||||
🔴 CHỈ liệt kê NCC ĐÃ CÓ trên phiếu: NCC đến từ winner của phiếu Duyệt NCC;
|
||||
đẻ NCC mới ở đây là ký với nhà thầu chưa ai chọn (BE cũng chặn, 409). */}
|
||||
{isDraft && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[11px] text-slate-500">Thêm hạng mục cho:</span>
|
||||
{[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => (
|
||||
<button
|
||||
key={l.supplierId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddFor({ supplierId: l.supplierId, supplierName: l.supplierName })
|
||||
setAddCatalogId(''); setAddAmount(''); setAddNote('')
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50"
|
||||
>
|
||||
<Plus className="h-3 w-3" /> {l.supplierName ?? '(không rõ NCC)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addFor && (
|
||||
<div className="mt-2 rounded-md border border-brand-200 bg-brand-50/50 p-3">
|
||||
<div className="mb-2 text-[12px] font-semibold text-brand-800">
|
||||
Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-[14rem] flex-1">
|
||||
<span className="label-eyebrow">Hạng mục</span>
|
||||
<select
|
||||
value={addCatalogId}
|
||||
onChange={(e) => setAddCatalogId(e.target.value)}
|
||||
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700"
|
||||
>
|
||||
<option value="">— Chọn hạng mục —</option>
|
||||
{(catalogOptions.data ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.code} — {c.tenVi}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="w-[9rem]">
|
||||
<span className="label-eyebrow">Giá đề xuất</span>
|
||||
<input
|
||||
type="number" min={0} step={1000}
|
||||
value={addAmount}
|
||||
onChange={(e) => setAddAmount(e.target.value)}
|
||||
placeholder="0"
|
||||
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-[10rem] flex-1">
|
||||
<span className="label-eyebrow">Ghi chú</span>
|
||||
<input
|
||||
type="text" maxLength={1000}
|
||||
value={addNote}
|
||||
onChange={(e) => setAddNote(e.target.value)}
|
||||
placeholder="(tùy chọn)"
|
||||
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
|
||||
onClick={() => addLine.mutate({
|
||||
supplierId: addFor.supplierId,
|
||||
catalogEntryId: addCatalogId,
|
||||
proposedAmount: Number(addAmount),
|
||||
note: addNote.trim() || undefined,
|
||||
})}
|
||||
>
|
||||
{addLine.isPending ? 'Đang thêm…' : 'Thêm'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
||||
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu ở Nháp/Trả lại — mọi dòng phải gán xong mới trình duyệt được.
|
||||
{' '}🔴 Dòng THÊM TAY không có giá tham chiếu (hiện “—”) — số tham chiếu là của cả NCC, đã nằm ở dòng gốc.
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ─────────────── 3 ─────────────── */}
|
||||
{/* ─────── 3 — GỘP "Căn cứ hồ sơ" + "File đính kèm" (anh chốt S175) ─────── */}
|
||||
{/* Anh: "file 3+4 -> Hợp thành file đính kèm như của Duyệt NCC nhé".
|
||||
Khuôn dòng file bê từ PE `PeDetailTabs.tsx:3382-3418`: 1 danh sách PHẲNG, mỗi
|
||||
dòng có badge cho biết file thuộc căn cứ nào (hoặc "Hồ sơ phiếu" nếu ở mức phiếu).
|
||||
🔴 Bảng căn cứ 5 cột bị GỠ khỏi màn, nhưng MÁY GIỮ NGUYÊN: `kind`/`status`/TVGS/
|
||||
ghi-chú vẫn sửa được qua nút ✎ (mở đúng dialog cũ, đủ 5 trường) và vẫn hiện dưới
|
||||
dạng badge. Gỡ luôn entity để "cho gọn" thì mất dữ-liệu BE đang ghi. */}
|
||||
<Section
|
||||
title="3. Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)"
|
||||
icon={<ClipboardList className="h-4 w-4" />}
|
||||
accent="var(--color-violet-500)"
|
||||
head="text-violet-700"
|
||||
chipBg="var(--color-violet-50)"
|
||||
chipFg="var(--color-violet-700)"
|
||||
count={plan.dossierItems.length}
|
||||
actions={
|
||||
isDraft ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{plan.dossierItems.length === 0 ? (
|
||||
<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 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>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Tên căn cứ</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Trạng thái</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">TVGS</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
|
||||
{isDraft && <th className="label-eyebrow px-3 py-2 text-right">Thao tác</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.dossierItems.map((d: KhkkDossierItemDto) => {
|
||||
// [S175 · O4] File gắn ĐÚNG căn cứ này (shopdrawing, mẫu vật liệu…).
|
||||
const files = dossierAttachments.get(d.id) ?? []
|
||||
return (
|
||||
<Fragment key={d.id}>
|
||||
<tr className={cn('border-slate-100 hover:bg-slate-50/60', files.length === 0 && 'border-b last:border-0')}>
|
||||
<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-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-slate-600">{orDash(d.note)}</td>
|
||||
{isDraft && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `<label>` bọc input
|
||||
ẩn thay vì `<button>`+ref: mỗi dòng cần một ô chọn file riêng,
|
||||
dùng ref thì phải nuôi N ref cho N dòng. */}
|
||||
<label
|
||||
title="Đính kèm file cho căn cứ này (shopdrawing, mẫu vật liệu…)"
|
||||
className="cursor-pointer rounded px-1.5 py-1 text-teal-600 hover:bg-teal-50"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => onPickFiles(e, d.id)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setItemForm({
|
||||
id: d.id, kind: d.kind, supplierId: d.supplierId, name: d.name,
|
||||
status: d.status, tvgsName: d.tvgsName ?? '', note: d.note ?? '',
|
||||
})}
|
||||
className="rounded px-1.5 py-1 text-violet-600 hover:bg-violet-50"
|
||||
title="Sửa"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xóa căn cứ "${d.name}"?`)) deleteItem.mutate(d.id) }}
|
||||
className="rounded px-1.5 py-1 text-red-600 hover:bg-red-50"
|
||||
title="Xóa"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{/* [S175 · O4] Dòng-con liệt kê file của căn cứ. Hiện CẢ KHI read-only —
|
||||
người duyệt cần xem được shopdrawing, chỉ không được thêm/xoá. */}
|
||||
{files.length > 0 && (
|
||||
<tr className="border-b border-slate-100 last:border-0">
|
||||
<td colSpan={isDraft ? 6 : 5} className="bg-slate-50/40 px-3 pb-2">
|
||||
<div className="flex flex-wrap gap-1.5 pl-3">
|
||||
{files.map((a) => (
|
||||
<span
|
||||
key={a.id}
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px]"
|
||||
>
|
||||
<Paperclip className="h-3 w-3 shrink-0 text-teal-500" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(a)}
|
||||
title={`Tải xuống ${a.fileName} (${formatSize(a.fileSize)})`}
|
||||
className="max-w-[16rem] truncate text-slate-700 hover:text-brand-700 hover:underline"
|
||||
>
|
||||
{a.fileName}
|
||||
</button>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
|
||||
{isDraft && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
|
||||
title="Xóa file"
|
||||
className="shrink-0 rounded text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ─────────────── 4 ─────────────── */}
|
||||
<Section
|
||||
title="4. File đính kèm"
|
||||
title="3. File đính kèm (căn cứ hồ sơ / shopdrawing)"
|
||||
icon={<Paperclip className="h-4 w-4" />}
|
||||
accent="var(--color-teal-500)"
|
||||
head="text-teal-700"
|
||||
chipBg="var(--color-teal-50)"
|
||||
chipFg="var(--color-teal-700)"
|
||||
count={planAttachments.length}
|
||||
count={mergedRows.length}
|
||||
actions={
|
||||
readOnly ? undefined : (
|
||||
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
|
||||
<Upload className="mr-2 h-3.5 w-3.5" />
|
||||
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
|
||||
<input type="file" multiple className="hidden" onChange={onPickFiles} />
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDraft && (
|
||||
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
|
||||
</Button>
|
||||
)}
|
||||
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
|
||||
<Upload className="mr-2 h-3.5 w-3.5" />
|
||||
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
|
||||
<input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e)} />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{planAttachments.length === 0 ? (
|
||||
<div className="text-sm italic text-slate-400">Chưa có file đính kèm.</div>
|
||||
{mergedRows.length === 0 ? (
|
||||
<div className="text-sm italic text-slate-400">Chưa có căn cứ hồ sơ hay file đính kèm nào.</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{planAttachments.map((a) => (
|
||||
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-brand-800">{a.fileName}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button type="button" onClick={() => download(a)} className="text-xs text-primary hover:underline">Tải xuống</button>
|
||||
<div className="space-y-1">
|
||||
{mergedRows.map((r) => (
|
||||
r.rk === 'file' ? (
|
||||
<div key={r.att.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" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-slate-700" title={r.att.fileName}>{r.att.fileName}</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(r.att.fileSize)}</span>
|
||||
<span className={cn(
|
||||
'shrink-0 rounded px-1 text-[9px] font-medium',
|
||||
r.item ? 'bg-violet-100 text-violet-700' : 'bg-slate-200 text-slate-600',
|
||||
)}>
|
||||
{r.item ? (DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ') : 'Hồ sơ phiếu'}
|
||||
</span>
|
||||
{r.item && (
|
||||
<span className="hidden max-w-[10rem] shrink-0 truncate text-[10px] text-slate-500 sm:inline" title={r.item.name}>
|
||||
{r.item.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{formatDateTime(r.att.createdAt)}</span>
|
||||
{isPreviewable(r.att.fileName) && (
|
||||
<button type="button" onClick={() => setPreviewAtt(r.att)} 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={() => download(r.att)} className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50" title="Tải xuống">
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
|
||||
className="text-xs text-red-600 hover:underline"
|
||||
onClick={() => { if (window.confirm(`Xóa file "${r.att.fileName}"?`)) deleteAttachment.mutate(r.att.id) }}
|
||||
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
|
||||
title="Xóa file"
|
||||
>
|
||||
Xóa
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
) : (
|
||||
/* Căn cứ ĐÃ KHAI mà chưa có file — vẫn phải hiện, không thì khai xong nó
|
||||
biến mất và người dùng tưởng bấm hụt. */
|
||||
<div key={r.item.id} className="flex items-center gap-1.5 rounded border border-dashed border-slate-200 bg-white px-1.5 py-1 text-[11px]">
|
||||
<ClipboardList className="h-3 w-3 shrink-0 text-violet-400" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-slate-600" title={r.item.name}>{r.item.name}</span>
|
||||
<span className="shrink-0 rounded bg-violet-100 px-1 text-[9px] font-medium text-violet-700">
|
||||
{DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ'}
|
||||
</span>
|
||||
<span className={cn('shrink-0 rounded border px-1 text-[9px] font-medium', DOSSIER_STATUS_BADGE[r.item.status])}>
|
||||
{DOSSIER_STATUS_LABELS[r.item.status]}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] italic text-slate-400">chưa có file</span>
|
||||
{isDraft && (
|
||||
<>
|
||||
<label title="Đính kèm file cho căn cứ này" className="shrink-0 cursor-pointer rounded px-1 text-teal-600 hover:bg-teal-50">
|
||||
<Upload className="h-3 w-3" />
|
||||
<input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e, r.item.id)} />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setItemForm({
|
||||
id: r.item.id, kind: r.item.kind, supplierId: r.item.supplierId, name: r.item.name,
|
||||
status: r.item.status, tvgsName: r.item.tvgsName ?? '', note: r.item.note ?? '',
|
||||
})}
|
||||
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
|
||||
title="Sửa căn cứ"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xóa căn cứ "${r.item.name}"?`)) deleteItem.mutate(r.item.id) }}
|
||||
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
|
||||
title="Xóa căn cứ"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{previewAtt && (
|
||||
<AttachmentPreviewDialog
|
||||
open
|
||||
basePath="/contract-signing-plans"
|
||||
evaluationId={plan.id}
|
||||
attachmentId={previewAtt.id}
|
||||
fileName={previewAtt.fileName}
|
||||
onClose={() => setPreviewAtt(null)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@ -18,11 +18,14 @@
|
||||
// Không dựng workspace riêng cho việc này.
|
||||
// [R-8] KHKK KHÔNG có phiếu V1-legacy (module sinh 2026-07-29) ⇒ KHÔNG port nhánh fallback
|
||||
// V1 của PE (`PeDetailTabs.tsx:434-436`) — đó là code chết ngay ngày đầu.
|
||||
import { Fragment, useState, type ReactNode } from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
||||
ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
|
||||
} from 'lucide-react'
|
||||
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
|
||||
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
|
||||
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
|
||||
import { toast } from 'sonner'
|
||||
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
@ -158,6 +161,13 @@ export function KhkkDetailContent({
|
||||
const [ghiChu, setGhiChu] = useState('')
|
||||
const [hoSoLink, setHoSoLink] = useState('')
|
||||
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
|
||||
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
|
||||
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
|
||||
// [S175] NCC đang mở form "thêm hạng mục" (null = không mở form nào).
|
||||
const [addFor, setAddFor] = useState<{ supplierId: string; supplierName: string | null } | null>(null)
|
||||
const [addCatalogId, setAddCatalogId] = useState('')
|
||||
const [addAmount, setAddAmount] = useState('')
|
||||
const [addNote, setAddNote] = useState('')
|
||||
|
||||
const phase = plan.phase as KhkkPhaseValue
|
||||
const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
||||
@ -198,6 +208,32 @@ export function KhkkDetailContent({
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// [S175 — anh chốt "nhiều hạng mục → giá và ghi chú"] 3 mutation cho DÒNG hạng mục.
|
||||
// 🔴 Body null-safe: chỉ gửi field ĐANG sửa; field không gửi thì BE giữ nguyên
|
||||
// (`UpdateContractSigningPlanLineCommand` — khuôn bug-class #73/S42).
|
||||
const saveLineField = useMutation({
|
||||
mutationFn: async (p: { lineId: string; proposedAmount?: number; note?: string }) =>
|
||||
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, {
|
||||
proposedAmount: p.proposedAmount,
|
||||
note: p.note,
|
||||
}),
|
||||
onSuccess: () => { toast.success('Đã lưu dòng'); invalidate() },
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const addLine = useMutation({
|
||||
mutationFn: async (p: { supplierId: string; catalogEntryId: string; proposedAmount: number; note?: string }) =>
|
||||
api.post(`/contract-signing-plans/${id}/lines`, p),
|
||||
onSuccess: () => { toast.success('Đã thêm hạng mục'); setAddFor(null); invalidate() },
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const deleteLine = useMutation({
|
||||
mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`),
|
||||
onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() },
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const upsertItem = useMutation({
|
||||
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
|
||||
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
|
||||
@ -293,6 +329,25 @@ export function KhkkDetailContent({
|
||||
}
|
||||
}
|
||||
|
||||
// [S175 · anh chốt] GỘP mục 3 (Căn cứ hồ sơ) + mục 4 (File đính kèm) thành MỘT khối
|
||||
// "File đính kèm" khuôn Duyệt NCC (`PeDetailTabs.tsx:3382-3418`): 1 danh sách PHẲNG,
|
||||
// mỗi dòng = 1 file có badge cho biết nó thuộc căn cứ nào.
|
||||
// 🔴 CHỈ gộp phần HIỂN THỊ — máy căn cứ hồ sơ GIỮ NGUYÊN (entity `KhkkDossierItem`,
|
||||
// 3 endpoint POST/PUT/DELETE dossier-items, changelog). Xoá máy đi để "cho gọn giao diện"
|
||||
// là mất `kind`/`status`/TVGS — những thứ BE đang ghi và người duyệt đang đọc.
|
||||
// Căn cứ ĐÃ KHAI mà CHƯA có file vẫn phải hiện 1 dòng, nếu không thì khai xong nó biến mất
|
||||
// và người dùng tưởng bấm hụt.
|
||||
type MergedRow =
|
||||
| { rk: 'file'; att: KhkkAttachmentDto; item: KhkkDossierItemDto | null }
|
||||
| { rk: 'empty'; item: KhkkDossierItemDto }
|
||||
const mergedRows: MergedRow[] = []
|
||||
for (const d of plan.dossierItems) {
|
||||
const files = dossierAttachments.get(d.id) ?? []
|
||||
if (files.length === 0) mergedRows.push({ rk: 'empty', item: d })
|
||||
else for (const a of files) mergedRows.push({ rk: 'file', att: a, item: d })
|
||||
}
|
||||
for (const a of planAttachments) mergedRows.push({ rk: 'file', att: a, item: null })
|
||||
|
||||
// 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
|
||||
// này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
|
||||
// là kể một chuyện hai lần trên cùng màn hình. Dữ-liệu `plan.levelOpinions` /
|
||||
@ -525,213 +580,289 @@ export function KhkkDetailContent({
|
||||
</>
|
||||
) : orDash(l.tenHangMuc)}
|
||||
</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>
|
||||
{/* Dòng THÊM TAY có `peReferenceAmount = 0` — BE cố ý KHÔNG copy số tham
|
||||
chiếu của NCC (đã nằm trọn ở dòng gốc), lặp lại là đếm đôi. Hiện
|
||||
em-dash mờ thay vì "0 đ" để không đọc nhầm thành "tham chiếu bằng 0". */}
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">
|
||||
{l.peReferenceAmount === 0 ? DASH : formatVnd(l.peReferenceAmount)}
|
||||
</td>
|
||||
{/* [S175] GIÁ ĐỀ XUẤT nhập tay — anh chốt "nhiều hạng mục → giá và ghi chú".
|
||||
Lưu khi rời ô (onBlur) và chỉ khi SỐ ĐỔI THẬT, tránh bắn 1 request mỗi
|
||||
lần click vào rồi click ra. */}
|
||||
<td className="px-3 py-2 text-right tabular-nums text-brand-800">
|
||||
{isDraft && !l.contractId ? (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
defaultValue={l.proposedAmount}
|
||||
disabled={saveLineField.isPending}
|
||||
aria-label={`Giá đề xuất dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
|
||||
onBlur={(e) => {
|
||||
const v = Number(e.target.value)
|
||||
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
|
||||
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
|
||||
}}
|
||||
className="h-8 w-full min-w-[7rem] rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||
/>
|
||||
) : 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>
|
||||
<td className="px-3 py-2 text-slate-600">
|
||||
{isDraft && !l.contractId ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1000}
|
||||
defaultValue={l.note ?? ''}
|
||||
placeholder="Ghi chú…"
|
||||
disabled={saveLineField.isPending}
|
||||
aria-label={`Ghi chú dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim()
|
||||
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
|
||||
}}
|
||||
className="h-8 w-full min-w-[6rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||
/>
|
||||
{/* Chỉ dòng THÊM TAY mới xoá được. Dòng gốc sinh từ phiếu Duyệt NCC
|
||||
mang snapshot tham chiếu — BE cũng chặn, đây chỉ là không mời bấm. */}
|
||||
{l.peReferenceAmount === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
|
||||
disabled={deleteLine.isPending}
|
||||
title="Xoá hạng mục này"
|
||||
className="shrink-0 rounded px-1 py-1 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : orDash(l.note)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* [S175 — anh chốt] THÊM HẠNG MỤC cho 1 NCC. 1 NCC được N hạng mục: máy đã cho
|
||||
phép từ Mig 71 (unique 3 cột `Plan × Supplier × CatalogEntry`), lượt này chỉ
|
||||
bung UI — đúng khuôn "máy rộng, UI hẹp" anh chốt S164.
|
||||
🔴 CHỈ liệt kê NCC ĐÃ CÓ trên phiếu: NCC đến từ winner của phiếu Duyệt NCC;
|
||||
đẻ NCC mới ở đây là ký với nhà thầu chưa ai chọn (BE cũng chặn, 409). */}
|
||||
{isDraft && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[11px] text-slate-500">Thêm hạng mục cho:</span>
|
||||
{[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => (
|
||||
<button
|
||||
key={l.supplierId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddFor({ supplierId: l.supplierId, supplierName: l.supplierName })
|
||||
setAddCatalogId(''); setAddAmount(''); setAddNote('')
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50"
|
||||
>
|
||||
<Plus className="h-3 w-3" /> {l.supplierName ?? '(không rõ NCC)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addFor && (
|
||||
<div className="mt-2 rounded-md border border-brand-200 bg-brand-50/50 p-3">
|
||||
<div className="mb-2 text-[12px] font-semibold text-brand-800">
|
||||
Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-[14rem] flex-1">
|
||||
<span className="label-eyebrow">Hạng mục</span>
|
||||
<select
|
||||
value={addCatalogId}
|
||||
onChange={(e) => setAddCatalogId(e.target.value)}
|
||||
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700"
|
||||
>
|
||||
<option value="">— Chọn hạng mục —</option>
|
||||
{(catalogOptions.data ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.code} — {c.tenVi}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="w-[9rem]">
|
||||
<span className="label-eyebrow">Giá đề xuất</span>
|
||||
<input
|
||||
type="number" min={0} step={1000}
|
||||
value={addAmount}
|
||||
onChange={(e) => setAddAmount(e.target.value)}
|
||||
placeholder="0"
|
||||
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-[10rem] flex-1">
|
||||
<span className="label-eyebrow">Ghi chú</span>
|
||||
<input
|
||||
type="text" maxLength={1000}
|
||||
value={addNote}
|
||||
onChange={(e) => setAddNote(e.target.value)}
|
||||
placeholder="(tùy chọn)"
|
||||
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
|
||||
onClick={() => addLine.mutate({
|
||||
supplierId: addFor.supplierId,
|
||||
catalogEntryId: addCatalogId,
|
||||
proposedAmount: Number(addAmount),
|
||||
note: addNote.trim() || undefined,
|
||||
})}
|
||||
>
|
||||
{addLine.isPending ? 'Đang thêm…' : 'Thêm'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
Giá tham chiếu là SNAPSHOT tổng báo giá được chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
|
||||
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu ở Nháp/Trả lại — mọi dòng phải gán xong mới trình duyệt được.
|
||||
{' '}🔴 Dòng THÊM TAY không có giá tham chiếu (hiện “—”) — số tham chiếu là của cả NCC, đã nằm ở dòng gốc.
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ─────────────── 3 ─────────────── */}
|
||||
{/* ─────── 3 — GỘP "Căn cứ hồ sơ" + "File đính kèm" (anh chốt S175) ─────── */}
|
||||
{/* Anh: "file 3+4 -> Hợp thành file đính kèm như của Duyệt NCC nhé".
|
||||
Khuôn dòng file bê từ PE `PeDetailTabs.tsx:3382-3418`: 1 danh sách PHẲNG, mỗi
|
||||
dòng có badge cho biết file thuộc căn cứ nào (hoặc "Hồ sơ phiếu" nếu ở mức phiếu).
|
||||
🔴 Bảng căn cứ 5 cột bị GỠ khỏi màn, nhưng MÁY GIỮ NGUYÊN: `kind`/`status`/TVGS/
|
||||
ghi-chú vẫn sửa được qua nút ✎ (mở đúng dialog cũ, đủ 5 trường) và vẫn hiện dưới
|
||||
dạng badge. Gỡ luôn entity để "cho gọn" thì mất dữ-liệu BE đang ghi. */}
|
||||
<Section
|
||||
title="3. Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)"
|
||||
icon={<ClipboardList className="h-4 w-4" />}
|
||||
accent="var(--color-violet-500)"
|
||||
head="text-violet-700"
|
||||
chipBg="var(--color-violet-50)"
|
||||
chipFg="var(--color-violet-700)"
|
||||
count={plan.dossierItems.length}
|
||||
actions={
|
||||
isDraft ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{plan.dossierItems.length === 0 ? (
|
||||
<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 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>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Tên căn cứ</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Trạng thái</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">TVGS</th>
|
||||
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
|
||||
{isDraft && <th className="label-eyebrow px-3 py-2 text-right">Thao tác</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.dossierItems.map((d: KhkkDossierItemDto) => {
|
||||
// [S175 · O4] File gắn ĐÚNG căn cứ này (shopdrawing, mẫu vật liệu…).
|
||||
const files = dossierAttachments.get(d.id) ?? []
|
||||
return (
|
||||
<Fragment key={d.id}>
|
||||
<tr className={cn('border-slate-100 hover:bg-slate-50/60', files.length === 0 && 'border-b last:border-0')}>
|
||||
<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-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-slate-600">{orDash(d.note)}</td>
|
||||
{isDraft && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `<label>` bọc input
|
||||
ẩn thay vì `<button>`+ref: mỗi dòng cần một ô chọn file riêng,
|
||||
dùng ref thì phải nuôi N ref cho N dòng. */}
|
||||
<label
|
||||
title="Đính kèm file cho căn cứ này (shopdrawing, mẫu vật liệu…)"
|
||||
className="cursor-pointer rounded px-1.5 py-1 text-teal-600 hover:bg-teal-50"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => onPickFiles(e, d.id)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setItemForm({
|
||||
id: d.id, kind: d.kind, supplierId: d.supplierId, name: d.name,
|
||||
status: d.status, tvgsName: d.tvgsName ?? '', note: d.note ?? '',
|
||||
})}
|
||||
className="rounded px-1.5 py-1 text-violet-600 hover:bg-violet-50"
|
||||
title="Sửa"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xóa căn cứ "${d.name}"?`)) deleteItem.mutate(d.id) }}
|
||||
className="rounded px-1.5 py-1 text-red-600 hover:bg-red-50"
|
||||
title="Xóa"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{/* [S175 · O4] Dòng-con liệt kê file của căn cứ. Hiện CẢ KHI read-only —
|
||||
người duyệt cần xem được shopdrawing, chỉ không được thêm/xoá. */}
|
||||
{files.length > 0 && (
|
||||
<tr className="border-b border-slate-100 last:border-0">
|
||||
<td colSpan={isDraft ? 6 : 5} className="bg-slate-50/40 px-3 pb-2">
|
||||
<div className="flex flex-wrap gap-1.5 pl-3">
|
||||
{files.map((a) => (
|
||||
<span
|
||||
key={a.id}
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px]"
|
||||
>
|
||||
<Paperclip className="h-3 w-3 shrink-0 text-teal-500" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(a)}
|
||||
title={`Tải xuống ${a.fileName} (${formatSize(a.fileSize)})`}
|
||||
className="max-w-[16rem] truncate text-slate-700 hover:text-brand-700 hover:underline"
|
||||
>
|
||||
{a.fileName}
|
||||
</button>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
|
||||
{isDraft && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
|
||||
title="Xóa file"
|
||||
className="shrink-0 rounded text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ─────────────── 4 ─────────────── */}
|
||||
<Section
|
||||
title="4. File đính kèm"
|
||||
title="3. File đính kèm (căn cứ hồ sơ / shopdrawing)"
|
||||
icon={<Paperclip className="h-4 w-4" />}
|
||||
accent="var(--color-teal-500)"
|
||||
head="text-teal-700"
|
||||
chipBg="var(--color-teal-50)"
|
||||
chipFg="var(--color-teal-700)"
|
||||
count={planAttachments.length}
|
||||
count={mergedRows.length}
|
||||
actions={
|
||||
readOnly ? undefined : (
|
||||
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
|
||||
<Upload className="mr-2 h-3.5 w-3.5" />
|
||||
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
|
||||
<input type="file" multiple className="hidden" onChange={onPickFiles} />
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDraft && (
|
||||
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
|
||||
</Button>
|
||||
)}
|
||||
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
|
||||
<Upload className="mr-2 h-3.5 w-3.5" />
|
||||
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
|
||||
<input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e)} />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{planAttachments.length === 0 ? (
|
||||
<div className="text-sm italic text-slate-400">Chưa có file đính kèm.</div>
|
||||
{mergedRows.length === 0 ? (
|
||||
<div className="text-sm italic text-slate-400">Chưa có căn cứ hồ sơ hay file đính kèm nào.</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{planAttachments.map((a) => (
|
||||
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-brand-800">{a.fileName}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button type="button" onClick={() => download(a)} className="text-xs text-primary hover:underline">Tải xuống</button>
|
||||
<div className="space-y-1">
|
||||
{mergedRows.map((r) => (
|
||||
r.rk === 'file' ? (
|
||||
<div key={r.att.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" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-slate-700" title={r.att.fileName}>{r.att.fileName}</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(r.att.fileSize)}</span>
|
||||
<span className={cn(
|
||||
'shrink-0 rounded px-1 text-[9px] font-medium',
|
||||
r.item ? 'bg-violet-100 text-violet-700' : 'bg-slate-200 text-slate-600',
|
||||
)}>
|
||||
{r.item ? (DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ') : 'Hồ sơ phiếu'}
|
||||
</span>
|
||||
{r.item && (
|
||||
<span className="hidden max-w-[10rem] shrink-0 truncate text-[10px] text-slate-500 sm:inline" title={r.item.name}>
|
||||
{r.item.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{formatDateTime(r.att.createdAt)}</span>
|
||||
{isPreviewable(r.att.fileName) && (
|
||||
<button type="button" onClick={() => setPreviewAtt(r.att)} 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={() => download(r.att)} className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50" title="Tải xuống">
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
|
||||
className="text-xs text-red-600 hover:underline"
|
||||
onClick={() => { if (window.confirm(`Xóa file "${r.att.fileName}"?`)) deleteAttachment.mutate(r.att.id) }}
|
||||
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
|
||||
title="Xóa file"
|
||||
>
|
||||
Xóa
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
) : (
|
||||
/* Căn cứ ĐÃ KHAI mà chưa có file — vẫn phải hiện, không thì khai xong nó
|
||||
biến mất và người dùng tưởng bấm hụt. */
|
||||
<div key={r.item.id} className="flex items-center gap-1.5 rounded border border-dashed border-slate-200 bg-white px-1.5 py-1 text-[11px]">
|
||||
<ClipboardList className="h-3 w-3 shrink-0 text-violet-400" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-slate-600" title={r.item.name}>{r.item.name}</span>
|
||||
<span className="shrink-0 rounded bg-violet-100 px-1 text-[9px] font-medium text-violet-700">
|
||||
{DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ'}
|
||||
</span>
|
||||
<span className={cn('shrink-0 rounded border px-1 text-[9px] font-medium', DOSSIER_STATUS_BADGE[r.item.status])}>
|
||||
{DOSSIER_STATUS_LABELS[r.item.status]}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] italic text-slate-400">chưa có file</span>
|
||||
{isDraft && (
|
||||
<>
|
||||
<label title="Đính kèm file cho căn cứ này" className="shrink-0 cursor-pointer rounded px-1 text-teal-600 hover:bg-teal-50">
|
||||
<Upload className="h-3 w-3" />
|
||||
<input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e, r.item.id)} />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setItemForm({
|
||||
id: r.item.id, kind: r.item.kind, supplierId: r.item.supplierId, name: r.item.name,
|
||||
status: r.item.status, tvgsName: r.item.tvgsName ?? '', note: r.item.note ?? '',
|
||||
})}
|
||||
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
|
||||
title="Sửa căn cứ"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm(`Xóa căn cứ "${r.item.name}"?`)) deleteItem.mutate(r.item.id) }}
|
||||
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
|
||||
title="Xóa căn cứ"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{previewAtt && (
|
||||
<AttachmentPreviewDialog
|
||||
open
|
||||
basePath="/contract-signing-plans"
|
||||
evaluationId={plan.id}
|
||||
attachmentId={previewAtt.id}
|
||||
fileName={previewAtt.fileName}
|
||||
onClose={() => setPreviewAtt(null)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@ -123,7 +123,30 @@ public class ContractSigningPlansController(
|
||||
public async Task<IActionResult> UpdateLine(
|
||||
Guid id, Guid lineId, [FromBody] UpdateSigningPlanLineBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new UpdateContractSigningPlanLineCommand(id, lineId, body.CatalogEntryId), ct);
|
||||
await mediator.Send(new UpdateContractSigningPlanLineCommand(
|
||||
id, lineId, body.CatalogEntryId, body.ProposedAmount, body.Note), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// [S175] Thêm dòng hạng mục. 🔴 Policy `Update` chứ KHÔNG phải `Create`: "tạo" ở module này
|
||||
/// nghĩa là tạo PHIẾU (khuôn dossier-items `:142`); thêm dòng là SỬA phiếu đang soạn.
|
||||
/// Gotcha #85 — dùng ĐÚNG key của endpoint, không OR key con.
|
||||
[HttpPost("{id:guid}/lines")]
|
||||
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||
public async Task<ActionResult<Guid>> AddLine(
|
||||
Guid id, [FromBody] AddSigningPlanLineBody body, CancellationToken ct)
|
||||
{
|
||||
var lineId = await mediator.Send(new AddContractSigningPlanLineCommand(
|
||||
id, body.SupplierId, body.CatalogEntryId, body.ProposedAmount, body.Note), ct);
|
||||
return Ok(lineId);
|
||||
}
|
||||
|
||||
/// [S175] Xoá dòng hạng mục THÊM TAY (handler chặn dòng gốc sinh từ phiếu Duyệt NCC).
|
||||
[HttpDelete("{id:guid}/lines/{lineId:guid}")]
|
||||
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||
public async Task<IActionResult> DeleteLine(Guid id, Guid lineId, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeleteContractSigningPlanLineCommand(id, lineId), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -310,7 +333,19 @@ public class ContractSigningPlansController(
|
||||
bool? ApplyLevelFinalize = null);
|
||||
|
||||
// [S168 K8-blocker] Body gán hạng mục cho dòng — PlanId/LineId lấy từ route (1 nguồn sự thật).
|
||||
public record UpdateSigningPlanLineBody(Guid CatalogEntryId);
|
||||
// [S175] +2 field null-safe: `null` = client không gửi ⇒ giữ nguyên. `CatalogEntryId` đổi
|
||||
// sang nullable ⇒ call-site FE cũ (gửi đúng 1 field) KHÔNG phải sửa.
|
||||
public record UpdateSigningPlanLineBody(
|
||||
Guid? CatalogEntryId = null,
|
||||
decimal? ProposedAmount = null,
|
||||
string? Note = null);
|
||||
|
||||
/// [S175] Thêm dòng hạng mục cho NCC ĐÃ CÓ trên phiếu — anh chốt "nhiều hạng mục → giá và ghi chú".
|
||||
public record AddSigningPlanLineBody(
|
||||
Guid SupplierId,
|
||||
Guid CatalogEntryId,
|
||||
decimal ProposedAmount,
|
||||
string? Note = null);
|
||||
|
||||
// [K7 S167] Cầu → HĐ. `PlanId` lấy từ route (không nhận trong body — 1 nguồn sự thật).
|
||||
// `LineIds` nullable ở BODY để client gửi thiếu ra 400 của validator ("chọn ít nhất 1 dòng")
|
||||
|
||||
@ -617,10 +617,17 @@ public class UpdateContractSigningPlanDraftCommandHandler(
|
||||
// auto-gen (`:481-484`) HẸN nhưng chưa từng land (build-order K4b đổi việc sang `?group=`):
|
||||
// submit-guard vá-5 đã CHẶN line CatalogEntryId=null ⇒ thiếu command này thì MỌI phiếu tạo
|
||||
// sau K2 kẹt Nháp vĩnh viễn. Dry-run K8 bắt lỗ này trước giờ chạy.
|
||||
/// [S175 — anh chốt] "Chỉ thêm hạng mục → nhiều hạng mục → giá và ghi chú, vậy thôi."
|
||||
/// ⇒ 3 field, MỌI field null-safe theo khuôn `UpdateContractSigningPlanDraftCommand:559-563`:
|
||||
/// `null` = client KHÔNG gửi ⇒ giữ giá trị cũ (bug-class #73/S42 — form rút gọn null-hoá field
|
||||
/// nó không hiển thị). `CatalogEntryId` đổi từ bắt-buộc sang nullable ⇒ call-site cũ (FE gửi
|
||||
/// đúng 1 field `catalogEntryId`) KHÔNG phải sửa dòng nào.
|
||||
public record UpdateContractSigningPlanLineCommand(
|
||||
Guid PlanId,
|
||||
Guid LineId,
|
||||
Guid CatalogEntryId) : IRequest;
|
||||
Guid? CatalogEntryId = null,
|
||||
decimal? ProposedAmount = null,
|
||||
string? Note = null) : IRequest;
|
||||
|
||||
public class UpdateContractSigningPlanLineCommandValidator
|
||||
: AbstractValidator<UpdateContractSigningPlanLineCommand>
|
||||
@ -629,7 +636,10 @@ public class UpdateContractSigningPlanLineCommandValidator
|
||||
{
|
||||
RuleFor(x => x.PlanId).NotEmpty();
|
||||
RuleFor(x => x.LineId).NotEmpty();
|
||||
RuleFor(x => x.CatalogEntryId).NotEmpty();
|
||||
// 🔴 KHÔNG cho giá ÂM. Khác `AdjustmentAmount` của ngân sách (cái đó cho âm vì là
|
||||
// "hiệu chỉnh tăng GIẢM"); đây là GIÁ ĐỀ XUẤT của một hạng mục — âm là vô nghĩa.
|
||||
RuleFor(x => x.ProposedAmount).GreaterThanOrEqualTo(0).When(x => x.ProposedAmount.HasValue);
|
||||
RuleFor(x => x.Note).MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
|
||||
@ -650,23 +660,55 @@ public class UpdateContractSigningPlanLineCommandHandler(
|
||||
?? throw new NotFoundException("ContractSigningPlanLine", request.LineId);
|
||||
|
||||
// [gate-lineeditor F-6] Rào BE thật (FE khoá chỉ là hiển thị): dòng đã bắc cầu HĐ
|
||||
// thì hạng mục bất biến — đổi sau khi có HĐ = đổi lịch sử pháp lý của HĐ đã sinh.
|
||||
// thì BẤT BIẾN — đổi sau khi có HĐ = đổi lịch sử pháp lý của HĐ đã sinh.
|
||||
// 🔴 [S175] Rào này nay che CẢ TIỀN, không chỉ hạng mục: `ProposedAmount` là số đi vào
|
||||
// HĐ, sửa sau khi HĐ sinh cũng là viết lại lịch sử.
|
||||
if (line.ContractId != null)
|
||||
throw new ConflictException("Dòng đã được đưa vào hợp đồng — không đổi hạng mục được nữa.");
|
||||
throw new ConflictException("Dòng đã được đưa vào hợp đồng — không sửa được nữa.");
|
||||
|
||||
// Fail-fast tầng-gán (submit-guard vẫn là chốt CUỐI chống data cũ/lách API):
|
||||
// hạng mục phải CÒN SỐNG + đang hoạt động + ĐÚNG nhóm duyệt của phiếu.
|
||||
var entry = await db.ContractCatalogEntries.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CatalogEntryId, ct)
|
||||
?? throw new NotFoundException("ContractCatalogEntry", request.CatalogEntryId);
|
||||
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
|
||||
throw new ConflictException(
|
||||
$"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");
|
||||
var changes = new List<string>();
|
||||
|
||||
line.CatalogEntryId = entry.Id;
|
||||
// Denorm tên TẠI THỜI ĐIỂM GÁN (khuôn `SignedByFullName` Mig 26 — entity `:25-27`):
|
||||
// danh mục đổi tên sau KHÔNG viết lại lịch sử phiếu.
|
||||
line.TenHangMuc = entry.TenVi;
|
||||
if (request.CatalogEntryId is Guid catId)
|
||||
{
|
||||
// Fail-fast tầng-gán (submit-guard vẫn là chốt CUỐI chống data cũ/lách API):
|
||||
// hạng mục phải CÒN SỐNG + đang hoạt động + ĐÚNG nhóm duyệt của phiếu.
|
||||
var entry = await db.ContractCatalogEntries.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == catId, ct)
|
||||
?? throw new NotFoundException("ContractCatalogEntry", catId);
|
||||
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
|
||||
throw new ConflictException(
|
||||
$"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");
|
||||
|
||||
// 🔴 [S175] Đổi hạng mục có thể ĐỤNG UNIQUE (PlanId, SupplierId, CatalogEntryId):
|
||||
// NCC nay được N dòng ⇒ đổi dòng này trùng hạng mục dòng khác CÙNG NCC là có thật.
|
||||
// Bắt TRƯỚC khi SaveChanges để trả 409 đọc được, thay vì 500 unique-violation.
|
||||
if (plan.Lines.Any(l => l.Id != line.Id && l.SupplierId == line.SupplierId && l.CatalogEntryId == catId))
|
||||
throw new ConflictException(
|
||||
$"NCC này đã có dòng cho hạng mục '{entry.TenVi}' — mỗi cặp NCC × hạng mục chỉ 1 dòng.");
|
||||
|
||||
line.CatalogEntryId = entry.Id;
|
||||
// Denorm tên TẠI THỜI ĐIỂM GÁN (khuôn `SignedByFullName` Mig 26 — entity `:25-27`):
|
||||
// danh mục đổi tên sau KHÔNG viết lại lịch sử phiếu.
|
||||
line.TenHangMuc = entry.TenVi;
|
||||
changes.Add($"hạng mục → '{entry.Code} — {entry.TenVi}'");
|
||||
}
|
||||
|
||||
// [S175] Ghi changelog theo cặp CŨ→MỚI: đây là TIỀN, người duyệt phải truy được ai
|
||||
// đổi từ bao nhiêu sang bao nhiêu, không chỉ biết "có sửa".
|
||||
if (request.ProposedAmount is decimal amt && amt != line.ProposedAmount)
|
||||
{
|
||||
changes.Add($"giá đề xuất {line.ProposedAmount:N0} → {amt:N0} đ");
|
||||
line.ProposedAmount = amt;
|
||||
}
|
||||
|
||||
if (request.Note != null)
|
||||
{
|
||||
// Chuỗi rỗng = client CHỦ ĐỘNG xoá ⇒ null (khuôn `HoSoLink`).
|
||||
var note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
|
||||
if (note != line.Note) { changes.Add("ghi chú"); line.Note = note; }
|
||||
}
|
||||
|
||||
if (changes.Count == 0) return; // không có gì đổi ⇒ đừng đẻ changelog rỗng
|
||||
|
||||
// [gate-lineeditor F-1] Phiếu liên danh N dòng ⇒ nhật ký PHẢI trỏ được DÒNG NÀO:
|
||||
// EntityId = line.Id (quy ước mọi changelog thực-thể-con của file) + tên NCC vào Summary.
|
||||
@ -684,7 +726,159 @@ public class UpdateContractSigningPlanLineCommandHandler(
|
||||
PhaseAtChange = plan.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
UserName = currentUser.FullName ?? currentUser.Email,
|
||||
Summary = $"Gán hạng mục '{entry.Code} — {entry.TenVi}' cho dòng NCC {supplierName ?? "(không rõ tên)"}",
|
||||
Summary = $"Sửa dòng NCC {supplierName ?? "(không rõ tên)"}: {string.Join(" · ", changes)}",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== [S175] Thêm / xoá DÒNG hạng mục =============================
|
||||
// Anh chốt: "Chỉ thêm hạng mục → nhiều hạng mục → giá và ghi chú, vậy thôi."
|
||||
// 🔴 0 MIGRATION: unique `(PlanId, SupplierId, CatalogEntryId)` filter `IsDeleted=0` đã có
|
||||
// từ Mig 71 ⇒ 1 NCC × N hạng mục **máy đã cho phép từ lâu**, chỉ UI khoá 1-1. Đây đúng khuôn
|
||||
// "máy rộng, UI hẹp" anh chốt S164 — nay bung UI, KHÔNG đụng máy.
|
||||
|
||||
public record AddContractSigningPlanLineCommand(
|
||||
Guid PlanId,
|
||||
Guid SupplierId,
|
||||
Guid CatalogEntryId,
|
||||
decimal ProposedAmount,
|
||||
string? Note = null) : IRequest<Guid>;
|
||||
|
||||
public class AddContractSigningPlanLineCommandValidator
|
||||
: AbstractValidator<AddContractSigningPlanLineCommand>
|
||||
{
|
||||
public AddContractSigningPlanLineCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PlanId).NotEmpty();
|
||||
RuleFor(x => x.SupplierId).NotEmpty();
|
||||
// 🔴 BẮT BUỘC, khác đường auto-sinh (dòng gốc để null có chủ đích): unique index CỐ Ý
|
||||
// KHÔNG lọc `CatalogEntryId IS NOT NULL` (`ContractSigningPlanLineConfiguration.cs:35-38`)
|
||||
// ⇒ thêm dòng NULL sẽ đụng ngay dòng gốc của chính NCC đó.
|
||||
RuleFor(x => x.CatalogEntryId).NotEmpty();
|
||||
RuleFor(x => x.ProposedAmount).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.Note).MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
|
||||
public class AddContractSigningPlanLineCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<AddContractSigningPlanLineCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(AddContractSigningPlanLineCommand request, CancellationToken ct)
|
||||
{
|
||||
var plan = await db.ContractSigningPlans
|
||||
.Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.PlanId, ct)
|
||||
?? throw new NotFoundException("ContractSigningPlan", request.PlanId);
|
||||
|
||||
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);
|
||||
|
||||
// 🔴 NCC phải ĐÃ CÓ trên phiếu. NCC đến từ winner của phiếu Duyệt NCC
|
||||
// (`ContractSigningPlanFeatures.cs:508-531`); cho đẻ NCC mới ở đây = lách cả vòng
|
||||
// duyệt NCC, ký hợp đồng với nhà thầu chưa ai chọn.
|
||||
if (!plan.Lines.Any(l => l.SupplierId == request.SupplierId))
|
||||
throw new ConflictException(
|
||||
"NCC không thuộc kế hoạch này — chỉ thêm hạng mục cho NCC đã trúng thầu trên phiếu Duyệt NCC.");
|
||||
|
||||
var entry = await db.ContractCatalogEntries.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CatalogEntryId, ct)
|
||||
?? throw new NotFoundException("ContractCatalogEntry", request.CatalogEntryId);
|
||||
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
|
||||
throw new ConflictException(
|
||||
$"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");
|
||||
|
||||
// Bắt trùng TRƯỚC SaveChanges ⇒ 409 đọc được thay vì 500 unique-violation.
|
||||
if (plan.Lines.Any(l => l.SupplierId == request.SupplierId && l.CatalogEntryId == entry.Id))
|
||||
throw new ConflictException(
|
||||
$"NCC này đã có dòng cho hạng mục '{entry.TenVi}' — mỗi cặp NCC × hạng mục chỉ 1 dòng.");
|
||||
|
||||
var line = new ContractSigningPlanLine
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = request.SupplierId,
|
||||
CatalogEntryId = entry.Id,
|
||||
TenHangMuc = entry.TenVi,
|
||||
// 🔴 = 0, KHÔNG copy số tham chiếu của NCC. `PeReferenceAmount` là Σ báo giá ĐƯỢC
|
||||
// CHỌN của CẢ NCC đó trên phiếu PE, đã nằm trọn ở dòng gốc (`:512-515`). Lặp lại ở
|
||||
// dòng thêm tay = ĐẾM ĐÔI tiền tham chiếu của dự án. Nó cũng là DẤU phân biệt
|
||||
// dòng-gốc ⟂ dòng-thêm-tay mà `Delete` dựa vào để chặn xoá nhầm.
|
||||
PeReferenceAmount = 0m,
|
||||
ProposedAmount = request.ProposedAmount,
|
||||
Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim(),
|
||||
};
|
||||
db.ContractSigningPlanLines.Add(line);
|
||||
|
||||
var supplierName = await db.Suppliers.AsNoTracking()
|
||||
.Where(x => x.Id == request.SupplierId)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
EntityType = ContractSigningPlanEntityType.Line,
|
||||
EntityId = line.Id,
|
||||
Action = ChangelogAction.Insert,
|
||||
PhaseAtChange = plan.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
UserName = currentUser.FullName ?? currentUser.Email,
|
||||
Summary = $"Thêm hạng mục '{entry.Code} — {entry.TenVi}' cho NCC {supplierName ?? "(không rõ tên)"} — "
|
||||
+ $"giá đề xuất {request.ProposedAmount:N0} đ",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return line.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record DeleteContractSigningPlanLineCommand(Guid PlanId, Guid LineId) : IRequest;
|
||||
|
||||
public class DeleteContractSigningPlanLineCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<DeleteContractSigningPlanLineCommand>
|
||||
{
|
||||
public async Task Handle(DeleteContractSigningPlanLineCommand request, CancellationToken ct)
|
||||
{
|
||||
var plan = await db.ContractSigningPlans
|
||||
.Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.PlanId, ct)
|
||||
?? throw new NotFoundException("ContractSigningPlan", request.PlanId);
|
||||
|
||||
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);
|
||||
|
||||
var line = plan.Lines.FirstOrDefault(l => l.Id == request.LineId)
|
||||
?? throw new NotFoundException("ContractSigningPlanLine", request.LineId);
|
||||
|
||||
if (line.ContractId != null)
|
||||
throw new ConflictException("Dòng đã được đưa vào hợp đồng — không xoá được.");
|
||||
|
||||
// 🔴 CHỈ xoá được dòng THÊM TAY. Dòng gốc (`PeReferenceAmount != 0`) sinh từ winner của
|
||||
// phiếu Duyệt NCC và mang SNAPSHOT tham chiếu — xoá là mất số gốc, và mất luôn dấu vết
|
||||
// NCC đó từng trúng thầu. Muốn bỏ NCC thì sửa ở phiếu PE, không phải xoá lén ở đây.
|
||||
if (line.PeReferenceAmount != 0m)
|
||||
throw new ConflictException(
|
||||
"Đây là dòng gốc sinh từ phiếu Duyệt NCC — không xoá được. Chỉ xoá được hạng mục thêm tay.");
|
||||
|
||||
var supplierName = await db.Suppliers.AsNoTracking()
|
||||
.Where(x => x.Id == line.SupplierId)
|
||||
.Select(x => x.Name)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
var tenHangMuc = line.TenHangMuc ?? "(chưa gán hạng mục)";
|
||||
|
||||
db.ContractSigningPlanLines.Remove(line);
|
||||
|
||||
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
EntityType = ContractSigningPlanEntityType.Line,
|
||||
EntityId = line.Id,
|
||||
Action = ChangelogAction.Delete,
|
||||
PhaseAtChange = plan.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
UserName = currentUser.FullName ?? currentUser.Email,
|
||||
Summary = $"Xoá hạng mục '{tenHangMuc}' của NCC {supplierName ?? "(không rõ tên)"}",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
Reference in New Issue
Block a user