[CLAUDE] Contract: KHKK them N hang muc (gia+ghi chu) + gop muc 3-4 file dinh kem (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m32s

Nguon: /fable-real investigator-codebase (wf_a574cff1-6b9, 1 lane, 169K tok).
Spec 3-muc: runs/2026-08-05-S175-duan-budget-column/spec-hang-muc-nhieu-dong-va-cot-du-an-05-08-2026.md
Anh chot: "Chi them hang muc -> nhieu hang muc -> gia va ghi chu, vay thoi" + "file 3+4 ->
Hop thanh file dinh kem nhu cua Duyet NCC nhe".

=== 1. THEM N HANG MUC / NCC (0 MIGRATION) ===
🔴 Cau hoi TIEN da co dap an: KHONG co luat chia — NGUOI NHAP TAY tung dong.
Truoc do lead dung lai vi hoi "tach N hang muc thi chia tien NCC the nao"; anh tra loi khong
theo tien, nhap tay. Het chan.

BE — 0 migration (unique 3 cot Plan x Supplier x CatalogEntry co tu Mig 71; may DA rong,
lan nay chi bung UI — dung khuon "may rong, UI hep" anh chot S164):
· UpdateContractSigningPlanLineCommand +ProposedAmount? +Note?, CatalogEntryId -> NULLABLE.
  Null-safe khuon UpdateDraftCommand:559-563 => call-site FE cu (gui dung 1 field) KHONG phai
  sua dong nao.
· AddContractSigningPlanLineCommand (MOI) + route POST {id}/lines.
  🔴 SupplierId PHAI thuoc phieu — NCC den tu winner phieu Duyet NCC; de NCC moi o day = ky
    voi nha thau chua ai chon. Khong thuoc => 409.
  🔴 CatalogEntryId BAT BUOC: unique index CO Y khong loc "IS NOT NULL"
    (ContractSigningPlanLineConfiguration.cs:35-38) => them dong NULL se dung dong goc.
  🔴 PeReferenceAmount = 0, KHONG copy so tham chieu cua NCC. Tham chieu la tong bao gia DUOC
    CHON cua CA NCC, da nam tron o dong goc (Features.cs:512-515). Lap lai o dong them tay =
    DEM DOI tien tham chieu du an. No cung la DAU phan biet dong-goc/dong-them-tay.
· DeleteContractSigningPlanLineCommand (MOI) + route DELETE {id}/lines/{lineId}.
  🔴 CHI xoa duoc dong THEM TAY (PeReferenceAmount == 0). Dong goc mang snapshot tham chieu —
    xoa la mat so goc + mat dau vet NCC tung trung thau.
· Bat TRUNG cap (Plan, Supplier, CatalogEntry) TRUOC SaveChanges => 409 doc duoc, thay vi 500
  unique-violation. Ap cho CA duong them LAN duong doi hang muc (NCC nay co N dong => doi
  trung hang muc dong khac cung NCC la co that).
· Rao ContractId nay che CA TIEN, khong chi hang muc: ProposedAmount la so di vao HD.
· Changelog ghi cap CU->MOI cho tien ("gia de xuat 1.000 -> 2.000 d") — day la tien, nguoi
  duyet phai truy duoc ai doi tu bao nhieu sang bao nhieu, khong chi biet "co sua".
· 3 route deu policy KeHoachKyKet.Update (gotcha #85 — dung key cua endpoint; "tao" o module
  nay nghia la tao PHIEU nen POST line van la Update).

FE: cot GIA DE XUAT + GHI CHU nhap duoc inline (chi Nhap/Tra lai, chi dong chua co HD), luu
onBlur va CHI khi so doi that. Nut "+ Them hang muc" liet ke dung NCC da co tren phieu. Dong
them tay hien "—" o cot tham chieu (khong phai "0 d" — de khong doc nham la tham chieu bang 0).

=== 2. GOP MUC 3 + 4 -> 1 KHOI "FILE DINH KEM" ===
Khuon dong file be tu PE (PeDetailTabs.tsx:3382-3418): 1 danh sach PHANG, moi dong co badge
cho biet file thuoc can cu nao (hoac "Ho so phieu" neu o muc phieu), + eye xem truoc + download.
🔴 Bang can cu 5 cot GO khoi man nhung MAY GIU NGUYEN: entity KhkkDossierItem + 3 endpoint +
changelog con nguyen; kind/status/TVGS/ghi-chu van sua qua nut ✎ (mo dung dialog cu, du 5
truong) va van hien duoi dang badge. Go luon entity de "cho gon giao dien" = mat du-lieu BE
dang ghi va nguoi duyet dang doc.
Can cu DA KHAI ma chua co file van hien 1 dong (dashed) — khong thi khai xong no bien mat va
nguoi dung tuong bam hut.

=== 3. ENGINE BAC 2 GIA-THUYET CUA LEAD (ghi lai vi la SAI CUA LEAD) ===
(a) Lead: "cot DU AN la DI-CHUNG module Budget cu bi Mig 50 rut ruot" — SAI. Cot sinh o
    e33481e 19-06 (Mig 56), SAU Mig 50 (13-06) 6 ngay, va CUNG COMMIT voi comment y-dinh
    PeWorkItemBudget.cs:19 "DU AN: hien thi FE-only (chua wire BE — sau moi co NGUOI DU AN
    NHAP)" => CHO-CHUA-SAN co chu dich, khong phai xac chet.
(b) Lead do "≥3 cho hardcode —" — THIEU. That = 11 o/app (Block A 3 + Block B 8), mirror 2 app.
(c) 🔑 PHAT HIEN: Project.BudgetTotal DA TON TAI tu Mig 2 (day-1), wired tron 2 dau (BE CRUD
    ProjectFeatures.cs + FE cot "Ngan sach" ProjectsPage.tsx:117 + form :205). Chi chua noi vao
    ma tran PE, va DU-LIEU RONG: Dev 61/69 du an NULL (8 cai co so deu la demo-seed gated-OFF
    prod). Rat co the CHINH LA thu anh noi "ben muc ngan sach co muc du an chua dien vao" —
    lop "co-che dung, du-lieu khong co" lan thu 7.

=== CON NO ===
🔴 Cot DU AN (11 o) CHUA LAND — cho anh chot 3 cau trong spec §③: (1) Sigma KHKK do vao O NAO
trong 11 o; (2) phieu DaDuyet thi cot DU AN live hay dong bang; (3) sua thang ProposedAmount
(delta tu-audit = Proposed − PeReference) du chua hay can field "dieu chinh" rieng.
🔴 CHUA soi mat prod 375/1366/1440 (5 lane NO-SCREENSHOT, gotcha #3).
🔸 Test-before cho lop luy-ke la BAT BUOC khi land cot DU AN (financial-critical, rules §7) —
  lan nay chua cham luy-ke nen chua no test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 20:54:15 +07:00
parent bd309fa600
commit b811e1125d
5 changed files with 946 additions and 369 deletions

View File

@ -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?

View File

@ -18,11 +18,14 @@
// Không dựng workspace riêng cho việc này. // 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 // [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. // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { 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' } 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 { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip' import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -158,6 +161,13 @@ export function KhkkDetailContent({
const [ghiChu, setGhiChu] = useState('') const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('') const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null) 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 phase = plan.phase as KhkkPhaseValue
const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
@ -198,6 +208,32 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)), 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({ const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => { mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ') 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 // 🧊 [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 // 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` / // 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)} ) : orDash(l.tenHangMuc)}
</td> </td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{moneyOrDash(l.peReferenceAmount)}</td> {/* Dòng THÊM TAY có `peReferenceAmount = 0` — BE cố ý KHÔNG copy số tham
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{moneyOrDash(l.proposedAmount)}</td> 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-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> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </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"> <div className="mt-3 text-xs text-muted-foreground">
Giá tham chiếu 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. Giá tham chiếu 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. {' '}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 giá tham chiếu (hiện &ldquo;&rdquo;) số tham chiếu của cả NCC, đã nằm dòng gốc.
</div> </div>
</Section> </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 <Section
title="3. Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)" title="3. File đính kèm (căn cứ hồ sơ / 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"
icon={<Paperclip className="h-4 w-4" />} icon={<Paperclip className="h-4 w-4" />}
accent="var(--color-teal-500)" accent="var(--color-teal-500)"
head="text-teal-700" head="text-teal-700"
chipBg="var(--color-teal-50)" chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)" chipFg="var(--color-teal-700)"
count={planAttachments.length} count={mergedRows.length}
actions={ actions={
readOnly ? undefined : ( readOnly ? undefined : (
<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"> <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 className="mr-2 h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải...' : 'Tải file lên'} {upload.isPending ? 'Đang tải...' : 'Tải file lên'}
<input type="file" multiple className="hidden" onChange={onPickFiles} /> <input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e)} />
</label> </label>
</div>
) )
} }
> >
{planAttachments.length === 0 ? ( {mergedRows.length === 0 ? (
<div className="text-sm italic text-slate-400">Chưa file đính kèm.</div> <div className="text-sm italic text-slate-400">Chưa căn cứ hồ hay file đính kèm nào.</div>
) : ( ) : (
<ul className="space-y-2"> <div className="space-y-1">
{planAttachments.map((a) => ( {mergedRows.map((r) => (
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm"> r.rk === 'file' ? (
<div className="min-w-0"> <div key={r.att.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<div className="truncate font-medium text-brand-800">{a.fileName}</div> <Paperclip className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
<div className="text-xs text-muted-foreground">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div> <span className="min-w-0 flex-1 truncate text-slate-700" title={r.att.fileName}>{r.att.fileName}</span>
</div> <span className="shrink-0 text-[10px] text-slate-400">{formatSize(r.att.fileSize)}</span>
<div className="flex shrink-0 gap-2"> <span className={cn(
<button type="button" onClick={() => download(a)} className="text-xs text-primary hover:underline">Tải xuống</button> '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 && ( {!readOnly && (
<button <button
type="button" type="button"
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }} onClick={() => { if (window.confirm(`Xóa file "${r.att.fileName}"?`)) deleteAttachment.mutate(r.att.id) }}
className="text-xs text-red-600 hover:underline" 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> </button>
)} )}
</div> </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 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> </Section>
</div> </div>

View File

@ -18,11 +18,14 @@
// Không dựng workspace riêng cho việc này. // 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 // [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. // 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { 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' } 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 { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip' import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@ -158,6 +161,13 @@ export function KhkkDetailContent({
const [ghiChu, setGhiChu] = useState('') const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('') const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null) 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 phase = plan.phase as KhkkPhaseValue
const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
@ -198,6 +208,32 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)), 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({ const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => { mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ') 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 // 🧊 [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 // 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` / // 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)} ) : orDash(l.tenHangMuc)}
</td> </td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{moneyOrDash(l.peReferenceAmount)}</td> {/* Dòng THÊM TAY có `peReferenceAmount = 0` — BE cố ý KHÔNG copy số tham
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{moneyOrDash(l.proposedAmount)}</td> 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-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> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </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"> <div className="mt-3 text-xs text-muted-foreground">
Giá tham chiếu 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. Giá tham chiếu 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. {' '}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 giá tham chiếu (hiện &ldquo;&rdquo;) số tham chiếu của cả NCC, đã nằm dòng gốc.
</div> </div>
</Section> </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 <Section
title="3. Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)" title="3. File đính kèm (căn cứ hồ sơ / 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"
icon={<Paperclip className="h-4 w-4" />} icon={<Paperclip className="h-4 w-4" />}
accent="var(--color-teal-500)" accent="var(--color-teal-500)"
head="text-teal-700" head="text-teal-700"
chipBg="var(--color-teal-50)" chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)" chipFg="var(--color-teal-700)"
count={planAttachments.length} count={mergedRows.length}
actions={ actions={
readOnly ? undefined : ( readOnly ? undefined : (
<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"> <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 className="mr-2 h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải...' : 'Tải file lên'} {upload.isPending ? 'Đang tải...' : 'Tải file lên'}
<input type="file" multiple className="hidden" onChange={onPickFiles} /> <input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e)} />
</label> </label>
</div>
) )
} }
> >
{planAttachments.length === 0 ? ( {mergedRows.length === 0 ? (
<div className="text-sm italic text-slate-400">Chưa file đính kèm.</div> <div className="text-sm italic text-slate-400">Chưa căn cứ hồ hay file đính kèm nào.</div>
) : ( ) : (
<ul className="space-y-2"> <div className="space-y-1">
{planAttachments.map((a) => ( {mergedRows.map((r) => (
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm"> r.rk === 'file' ? (
<div className="min-w-0"> <div key={r.att.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<div className="truncate font-medium text-brand-800">{a.fileName}</div> <Paperclip className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
<div className="text-xs text-muted-foreground">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div> <span className="min-w-0 flex-1 truncate text-slate-700" title={r.att.fileName}>{r.att.fileName}</span>
</div> <span className="shrink-0 text-[10px] text-slate-400">{formatSize(r.att.fileSize)}</span>
<div className="flex shrink-0 gap-2"> <span className={cn(
<button type="button" onClick={() => download(a)} className="text-xs text-primary hover:underline">Tải xuống</button> '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 && ( {!readOnly && (
<button <button
type="button" type="button"
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }} onClick={() => { if (window.confirm(`Xóa file "${r.att.fileName}"?`)) deleteAttachment.mutate(r.att.id) }}
className="text-xs text-red-600 hover:underline" 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> </button>
)} )}
</div> </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 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> </Section>
</div> </div>

View File

@ -123,7 +123,30 @@ public class ContractSigningPlansController(
public async Task<IActionResult> UpdateLine( public async Task<IActionResult> UpdateLine(
Guid id, Guid lineId, [FromBody] UpdateSigningPlanLineBody body, CancellationToken ct) 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(); return NoContent();
} }
@ -310,7 +333,19 @@ public class ContractSigningPlansController(
bool? ApplyLevelFinalize = null); 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). // [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). // [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") // `LineIds` nullable ở BODY để client gửi thiếu ra 400 của validator ("chọn ít nhất 1 dòng")

View File

@ -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=`): // 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 // 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. // 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( public record UpdateContractSigningPlanLineCommand(
Guid PlanId, Guid PlanId,
Guid LineId, Guid LineId,
Guid CatalogEntryId) : IRequest; Guid? CatalogEntryId = null,
decimal? ProposedAmount = null,
string? Note = null) : IRequest;
public class UpdateContractSigningPlanLineCommandValidator public class UpdateContractSigningPlanLineCommandValidator
: AbstractValidator<UpdateContractSigningPlanLineCommand> : AbstractValidator<UpdateContractSigningPlanLineCommand>
@ -629,7 +636,10 @@ public class UpdateContractSigningPlanLineCommandValidator
{ {
RuleFor(x => x.PlanId).NotEmpty(); RuleFor(x => x.PlanId).NotEmpty();
RuleFor(x => x.LineId).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); ?? 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Đ // [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) 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.");
var changes = new List<string>();
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): // 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. // 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() var entry = await db.ContractCatalogEntries.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == request.CatalogEntryId, ct) .FirstOrDefaultAsync(c => c.Id == catId, ct)
?? throw new NotFoundException("ContractCatalogEntry", request.CatalogEntryId); ?? throw new NotFoundException("ContractCatalogEntry", catId);
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup) if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
throw new ConflictException( 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."); $"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; line.CatalogEntryId = entry.Id;
// Denorm tên TẠI THỜI ĐIỂM GÁN (khuôn `SignedByFullName` Mig 26 — entity `:25-27`): // 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. // danh mục đổi tên sau KHÔNG viết lại lịch sử phiếu.
line.TenHangMuc = entry.TenVi; 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: // [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. // 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, PhaseAtChange = plan.Phase,
UserId = currentUser.UserId, UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email, UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Gán hạng mục '{entry.Code} — {entry.TenVi}' cho dòng NCC {supplierName ?? "(không tên)"}", Summary = $"Sửa dòng NCC {supplierName ?? "(không 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 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 tên)"}",
}); });
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);