diff --git a/fe-admin/src/components/khkk/KhkkDetailContent.tsx b/fe-admin/src/components/khkk/KhkkDetailContent.tsx
index 41c066a..9c25cad 100644
--- a/fe-admin/src/components/khkk/KhkkDetailContent.tsx
+++ b/fe-admin/src/components/khkk/KhkkDetailContent.tsx
@@ -42,7 +42,7 @@ import {
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput,
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
- type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
+ type KhkkDetailDto, type KhkkDossierItemDto, type KhkkLineDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput,
} from '@/types/khkk'
@@ -196,6 +196,39 @@ export function KhkkDetailContent({
onChanged?.()
}
+ // ─── [S178 — anh: "lúc mới khởi tạo chỉ cần 1 dòng này thôi"] ───────────────────────
+ // DÒNG GỐC CHƯA GÁN = dòng hệ thống tự sinh cho mỗi NCC trúng thầu (`Features.cs:526`),
+ // mang SNAPSHOT `peReferenceAmount` = Σ báo giá được chọn bên phiếu PE, và `catalogEntryId`
+ // để NULL CÓ CHỦ ĐÍCH (lúc tạo chưa biết sẽ ký theo hạng mục SP-002 nào).
+ // 🔴 Anh chốt: **VẪN SINH, chỉ ẨN + không tính TỔNG** — KHÔNG xoá. Vì xoá là mất 3 thứ:
+ // số PE gốc, dấu "NCC này từng trúng thầu", và `BudgetManualAmount` của HĐ sinh ra sau
+ // (`CreateContractFromSigningPlanFeatures.cs:200` = Σ `PeReferenceAmount` các dòng).
+ // BE cũng đang chặn cứng việc xoá nó (`Features.cs:875`) — ẩn ở FE nên KHÔNG phải nới rào.
+ // ⇒ Gán hạng mục cho dòng gốc thì nó HIỆN LẠI và vào TỔNG như dòng thường.
+ const isUnassignedSeed = (l: KhkkLineDto) => l.peReferenceAmount !== 0 && !l.catalogEntryId
+ const visibleLines = plan.lines.filter((l) => !isUnassignedSeed(l))
+
+ // 🔴 HỆ QUẢ DÂY CHUYỀN phải xử, không thì ẩn xong là KẸT: nút `+` bám vào TỪNG DÒNG (nó lấy
+ // NCC từ chính dòng đó — anh chốt vậy ở đợt-3 khi bỏ khối "Thêm hạng mục cho: [NCC]").
+ // Ẩn dòng ⇒ mất luôn nút ⇒ NCC đó KHÔNG CÒN ĐƯỜNG VÀO.
+ // Điều kiện đúng KHÔNG phải "bảng rỗng" mà là "còn NCC nào chưa có dòng nào HIỆN" — phiếu
+ // liên-danh nhiều NCC (PE multi-winner, Mig 58/59) mà chỉ xét bảng-rỗng thì NCC thứ 2 mất
+ // đường vào ngay khi NCC thứ nhất có 1 dòng.
+ const visibleSupplierIds = new Set(visibleLines.map((l) => l.supplierId))
+ const unreachableSuppliers = [
+ ...new Map(
+ plan.lines
+ .filter((l) => isUnassignedSeed(l) && !visibleSupplierIds.has(l.supplierId))
+ .map((l) => [l.supplierId, l.supplierName] as const),
+ ).entries(),
+ ]
+ // Dòng nhập TỰ MỞ khi còn NCC chưa với tới được ⇒ phiếu vừa tạo hiện đúng 1 dòng nhập như
+ // ảnh anh gửi. Gán xong 1 hạng mục thì NCC đó hết "unreachable" ⇒ dòng nhập tự đóng, quay
+ // về nếp cũ (bấm `+` trên dòng).
+ const effectiveAddFor = addFor ?? (isDraft && unreachableSuppliers.length > 0
+ ? { supplierId: unreachableSuppliers[0][0] }
+ : null)
+
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
const catalogOptions = useQuery({
@@ -238,6 +271,28 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)),
})
+ // ─── [S178] NHẬN dòng gốc thay vì đẻ dòng mới ───────────────────────────────────────
+ // 🔴 VÌ SAO PHẢI CÓ: ẩn dòng gốc chưa gán (anh chốt) mà không có đường này thì phiếu KẸT
+ // VĨNH VIỄN — submit-guard BE `ContractSigningPlanWorkflowService.cs:176` chặn trình khi
+ // còn BẤT KỲ dòng nào `CatalogEntryId == null`, mà dòng đó nay đã bị ẩn nên người soạn
+ // không còn ô nào để gán. Ẩn ở FE thôi là CHƯA ĐỦ.
+ // ⇒ Cách ra KHÔNG cần migration, cũng KHÔNG cần nới rào xoá của BE: hạng mục ĐẦU TIÊN của
+ // một NCC thì GÁN THẲNG vào chính dòng gốc của NCC đó (đúng vòng đời mà `Features.cs:530-534`
+ // thiết kế: "mồi NULL, người soạn gán sau"). Dòng gốc hết NULL ⇒ hiện ra thành dòng 1 ⇒
+ // submit-guard thông. Các hạng mục SAU mới đẻ dòng mới (`PeReferenceAmount = 0`).
+ // 🔴 BẤT BIẾN TIỀN GIỮ NGUYÊN: Σ `PeReferenceAmount` không đổi (dòng gốc vẫn giữ số PE của
+ // nó) ⇒ `BudgetManualAmount` của HĐ sinh sau (`CreateContractFromSigningPlanFeatures.cs:200`)
+ // vẫn đúng bằng số PE. Đây là lý do KHÔNG chọn hướng xoá dòng gốc.
+ const adoptSeedLine = useMutation({
+ mutationFn: async (p: { lineId: string; catalogEntryId: string; proposedAmount: number }) =>
+ api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, {
+ catalogEntryId: p.catalogEntryId,
+ proposedAmount: p.proposedAmount,
+ }),
+ 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() },
@@ -543,7 +598,10 @@ export function KhkkDetailContent({
- {plan.lines.map((l, i) => (
+ {/* [S178] `visibleLines`, KHÔNG phải `plan.lines`: dòng gốc chưa gán hạng mục
+ bị ẩn (xem khối tính ở trên). STT vẫn là thứ tự HIỂN THỊ nên đánh theo
+ danh sách ĐÃ LỌC — dùng index của mảng gốc sẽ thủng số ngay dòng đầu. */}
+ {visibleLines.map((l, i) => (
{/* [S177] STT = thứ tự HIỂN THỊ, không phải id nghiệp vụ. BE đã sắp
`OrderBy(CreatedAt).ThenBy(Id)` nên thứ tự này ổn định giữa 2 lần tải. */}
@@ -662,7 +720,7 @@ export function KhkkDetailContent({
trùng", đúng thứ chú-thích ở đó cấm.
⇒ Dòng này là **dòng nháp phía client**: trông và xếp cột y hệt dòng thật,
nhưng chỉ gọi API MỘT LẦN khi đã có hạng mục ⇒ máy giữ nguyên bất biến. */}
- {isDraft && addFor && (
+ {isDraft && effectiveAddFor && (
// [S177 — anh: "bỏ cả khối dòng nhập"] Bỏ VẺ KHỐI RIÊNG (nền brand + viền
// brand) — dùng đúng class của dòng dữ liệu thật ở trên, để nó đọc thành
// "một dòng nữa của bảng" chứ không phải "một hộp lạ chen vào giữa bảng".
@@ -680,6 +738,22 @@ export function KhkkDetailContent({
nhớ mình vừa bấm `+` ở dòng nào. Anh đã được nêu đánh đổi này và chốt
gỡ. Nếu về sau vướng: rẻ nhất là chèn dòng nhập NGAY DƯỚI dòng vừa bấm
(vị trí tự nói NCC) thay vì thêm lại chữ. */}
+ {/* [S178] Ô chọn NCC — CHỈ hiện khi thật sự không suy ra được: còn ≥2 NCC
+ chưa có dòng nào hiện. Phiếu 1 NCC (ca thường) KHÔNG thấy ô này, đúng
+ "máy rộng, UI hẹp". Thiếu nó thì phiếu liên-danh nhiều NCC sẽ có NCC
+ thứ 2 không bao giờ với tới được sau khi dòng gốc bị ẩn. */}
+ {unreachableSuppliers.length > 1 && !visibleSupplierIds.has(effectiveAddFor.supplierId) && (
+
+ )}
@@ -743,7 +841,11 @@ export function KhkkDetailContent({
ô hạng mục và con số Tổng rơi lệch một cột so với cột Giá bên trên. */}
Tổng
- {formatVnd(plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
+ {/* [S178 — anh chốt "TỔNG = Σ hạng mục thật"] Cộng theo `visibleLines`:
+ dòng gốc chưa gán KHÔNG vào TỔNG, nếu không thì con số trên màn không
+ khớp các dòng nhìn thấy và người dùng sẽ đọc thành lỗi. Người soạn tách
+ dần hạng mục thì TỔNG bò lên dần tới số PE. */}
+ {formatVnd(visibleLines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
@@ -1004,10 +1106,17 @@ export function KhkkDetailContent({
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
const noWorkflow = !plan.approvalWorkflowId
const blocked = noWorkflow || unassigned > 0 || plan.lines.length === 0
+ // 🔴 [S178] Câu chặn phải nói THỨ NGƯỜI DÙNG NHÌN THẤY. Dòng gốc chưa gán nay bị
+ // ẩn (anh chốt), nên câu cũ "còn N dòng chưa gán hạng mục" trỏ vào dòng KHÔNG CÓ
+ // trên màn ⇒ người dùng đi tìm mãi không ra. Khi mọi dòng chưa-gán đều là dòng gốc
+ // bị ẩn thì nói theo NCC + đúng thao tác gỡ kẹt (thêm hạng mục cho NCC đó).
+ const unassignedAllHidden = unassigned > 0
+ && plan.lines.filter(l => !l.catalogEntryId).every(isUnassignedSeed)
const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.'
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
- : unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
- : undefined
+ : unassignedAllHidden ? `Còn ${unassigned} nhà cung cấp chưa có hạng mục nào — thêm hạng mục cho họ trước khi trình.`
+ : unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
+ : undefined
return (