[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
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:
@ -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