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) && ( + + )} setAddFor({ supplierId: e.target.value })} + aria-label="Nhà cung cấp của dòng mới" + className="mb-1 h-7 w-full rounded-md border border-slate-200 bg-white px-2 text-[11px] text-slate-600 focus-visible:border-brand-500 focus-visible:outline-none" + > + {unreachableSuppliers.map(([sid, sname]) => ( + + ))} + + )}