// [S171 KHKK 3-panel] PANEL 2 (giữa) — nội-dung phiếu "Kế hoạch ký kết HĐ" (GĐ2). // File MIRROR SHA256 identical với fe-admin counterpart. // // 🔴 Khối ý-kiến cấp duyệt (Section 5) là bản thứ 3 của cùng một hình (PE // `PeDetailTabs.tsx:705-774` · HĐ `components/contracts/WorkflowHistoryPanel.tsx`). // Gộp được KHI VÀ CHỈ KHI BE 3 module thống-nhất shape `status` precompute — trước đó // gộp chỉ đẻ ra `if (module === …)`. // // Bố-cục mirror Duyệt NCC: dải 4 giai-đoạn trên cùng, rồi các Section ĐÁNH SỐ nối nhau // bằng `divide-y`, ý-kiến cấp duyệt nằm DƯỚI CÙNG (Ảnh 3 khoanh đỏ). // 1. Thông tin kế hoạch 4. File đính kèm // 2. Giá đề xuất theo NCC 5. Ý kiến cấp duyệt // 3. Căn cứ hồ sơ (b.8-9) // 🔸 KHÁC PE ở NỘI DUNG (miễn-trừ P2-3, có lý-do nghiệp-vụ): GĐ2 không có báo-giá / so-sánh // NCC (đã chốt ở GĐ1) mà có căn-cứ hồ-sơ TVGS. Giống nhau ở KHUNG, không ở chữ. // // [P2-7] `readOnly` là prop TỪ HOST — host quyết `phiếu ≠ Nháp ∨ user ≠ người soạn`. // 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 { useState, type ReactNode } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, 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' import { Dialog } from '@/components/ui/Dialog' import { Input } from '@/components/ui/Input' import { Label } from '@/components/ui/Label' import { Textarea } from '@/components/ui/Textarea' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' import { DOSSIER_KIND_LABELS, DOSSIER_STATUS_BADGE, DOSSIER_STATUS_LABELS, DossierItemKind, DossierItemStatus, 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 UpsertKhkkDossierItemInput, } from '@/types/khkk' function formatVnd(n: number | null | undefined): string { if (n === null || n === undefined) return '—' return n.toLocaleString('vi-VN') + ' đ' } // [S175 · B2 · P0] Ô RỖNG phải MỜ HƠN dữ-liệu thật. Trước đây em-dash thừa-hưởng đúng // màu chữ của ô (`text-slate-600/700`) ⇒ lướt bảng thì "—" trông y như một GIÁ-TRỊ. // Gom về một chỗ để các ô rỗng không mỗi nơi một kiểu. const DASH = /** Chuỗi có thể rỗng → em-dash mờ. */ function orDash(v: string | null | undefined): ReactNode { return v == null || v === '' ? DASH : v } /** Tiền: `formatVnd` trả CHUỖI "—" khi rỗng nên không tô mờ được — tách nhánh rỗng ra. */ function moneyOrDash(n: number | null | undefined): ReactNode { return n == null ? DASH : formatVnd(n) } /** * [S177 — anh: "hiển thị stt và mã hạng mục (Dự án) - mã hạng mục chi tiết"] * Ghép ` - ` cho dòng bảng hạng mục. * 🔴 LỌC rồi mới nối, KHÔNG nối rồi mới cắt: thiếu một vế mà cứ chèn " - " thì ra * "DAO_MONG - " hoặc " - A1-01" — dấu nối mồ côi trông như dữ liệu hỏng chứ không như * thiếu dữ liệu. Cả hai vế rỗng ⇒ trả null để chỗ gọi BỎ HẲN dòng mã, không vẽ ô trống. */ function joinMaHangMuc(maDuAn: string | null, maChiTiet: string | null): string | null { const parts = [maDuAn, maChiTiet].filter((s): s is string => s != null && s !== '') return parts.length === 0 ? null : parts.join(' - ') } function formatDateTime(iso: string): string { return new Date(iso).toLocaleString('vi-VN') } function formatSize(b: number): string { return b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB` } /** Khung Section ĐÁNH SỐ — mirror khuôn Section của màn Duyệt NCC (đánh số + divide-y). */ function Section({ title, icon, accent, head, chipBg, chipFg, count, actions, children, }: { title: string icon: ReactNode accent: string head: string chipBg: string chipFg: string count?: number actions?: ReactNode children: ReactNode }) { return (
{icon}

{title}

{count != null && ( {count} )} {actions && {actions}}
{children}
) } function Field({ label, value, mono, full, children, }: { label: string value?: ReactNode mono?: boolean full?: boolean children?: ReactNode }) { const empty = value == null || value === '' return (
{label}
{/* [S175 · C3] VALUE về `text-slate-900`, LABEL giữ `text-brand-600`. Trước đây MỌI value đều tô brand ⇒ màu thương-hiệu mất hết sức nặng: khi cả trang đều là màu nhấn thì không còn gì là nhấn, và không chừa được màu cho thứ THẬT SỰ cần nổi (NCC trúng thầu, cảnh báo vượt ngân sách). PE giữ slate cho value đúng vì lý do này. */}
{children ?? (empty ? '—' : value)}
) } const EMPTY_ITEM: UpsertKhkkDossierItemInput = { kind: DossierItemKind.MauVatLieu, name: '', status: DossierItemStatus.ChuaNop, tvgsName: '', note: '', } export function KhkkDetailContent({ plan, readOnly, onChanged, onDeleted, }: { plan: KhkkDetailDto /** [P2-7] Host quyết: `true` khi phiếu ≠ Nháp/Trả lại HOẶC user ≠ người soạn. */ readOnly: boolean onChanged?: () => void /** [S175] Gọi sau khi XÓA phiếu ở thanh nút đáy — host bỏ chọn / quay về danh sách. */ onDeleted?: () => void }) { const qc = useQueryClient() const id = plan.id const [itemForm, setItemForm] = useState(null) // [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp. const [previewAtt, setPreviewAtt] = useState(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 // Sửa được = phiếu còn ở Nháp/Trả lại VÀ host không khoá (readOnly). const isDraft = draftPhase && !readOnly const invalidate = () => { qc.invalidateQueries({ queryKey: ['khkk-detail', id] }) qc.invalidateQueries({ queryKey: ['khkk-list'] }) onChanged?.() } // 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({ queryKey: ['contract-catalog', plan.approvalGroup], queryFn: async () => (await api.get<{ id: string; code: string; tenVi: string }[]>('/catalogs/contract-catalog', { params: { approvalGroup: plan.approvalGroup }, })).data, enabled: isDraft && plan.approvalGroup != null, }) const assignLine = useMutation({ mutationFn: async (p: { lineId: string; catalogEntryId: string }) => api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, { catalogEntryId: p.catalogEntryId }), onSuccess: () => { toast.success('Đã gán hạng mục'); invalidate() }, 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)), }) // [S175 — anh: "phía dưới thêm các nút thao tác"] 2 mutation cho THANH NÚT ĐÁY. // 🔴 CỐ Ý chỉ ôm nhánh **Submit** — nhánh Approve/Return có máy phức tạp (ô-tích // level-finalize + upload file đính kèm SAU khi transition thành công) và nó ở LẠI // `KhkkWorkflowPanel`. Nhân bản nhánh đó sang đây là đẻ sự-thật thứ hai cho đường DUYỆT; // nhánh Submit thì chỉ có đúng `action` + `comment: null` nên bê là an toàn. const submitPlan = useMutation({ mutationFn: async () => { const body: KhkkTransitionInput = { action: KhkkTransitionAction.Submit, comment: null } return api.post(`/contract-signing-plans/${id}/transitions`, body) }, onSuccess: () => { toast.success('Đã gửi duyệt'); invalidate(); onChanged?.() }, onError: (e) => toast.error(getErrorMessage(e)), }) const deletePlan = useMutation({ mutationFn: async () => api.delete(`/contract-signing-plans/${id}`), onSuccess: () => { toast.success('Đã xóa phiếu'); onDeleted?.() }, 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ứ') const payload = { ...body, contractSigningPlanId: id, name: body.name.trim(), tvgsName: body.tvgsName?.trim() || null, note: body.note?.trim() || null, } // [F-4 S161] POST ép Id=null = LUÔN insert ⇒ "Sửa" phải đi PUT, không thì đẻ bản sao im lặng. if (body.id) await api.put(`/contract-signing-plans/${id}/dossier-items/${body.id}`, payload) else await api.post(`/contract-signing-plans/${id}/dossier-items`, payload) }, onSuccess: () => { toast.success('Đã lưu căn cứ'); setItemForm(null); invalidate() }, onError: (e) => toast.error(getErrorMessage(e)), }) const deleteItem = useMutation({ mutationFn: async (itemId: string) => api.delete(`/contract-signing-plans/${id}/dossier-items/${itemId}`), onSuccess: () => { toast.success('Đã xóa căn cứ'); invalidate() }, onError: (e) => toast.error(getErrorMessage(e)), }) // [S175 · O4] +`dossierItemId`: gắn file vào ĐÚNG một căn cứ hồ sơ (shopdrawing, mẫu vật // liệu…) thay vì đổ chung một rổ. 🔴 0 BE MỚI — rail `dossierItemId` đã đủ 6 tầng từ Mig 69 // (`KhkkAttachmentDto:241` ⟵ `POST /attachments` multipart `dossierItemId?`); trước đây FE // đơn giản là KHÔNG BAO GIỜ gửi trường đó lên, nên mọi file rơi về mức phiếu. const upload = useMutation({ mutationFn: async ({ file, dossierItemId }: { file: File; dossierItemId?: string }) => { const fd = new FormData() fd.append('file', file) fd.append('purpose', String(KhkkAttachmentPurpose.DossierScan)) if (dossierItemId) fd.append('dossierItemId', dossierItemId) return api.post(`/contract-signing-plans/${id}/attachments`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, }) }, onSuccess: () => { toast.success('Đã tải lên'); invalidate() }, onError: (e) => toast.error(getErrorMessage(e)), }) const deleteAttachment = useMutation({ mutationFn: async (attId: string) => api.delete(`/contract-signing-plans/${id}/attachments/${attId}`), onSuccess: () => { toast.success('Đã xóa file'); invalidate() }, onError: (e) => toast.error(getErrorMessage(e)), }) async function download(att: KhkkAttachmentDto) { try { const res = await api.get(`/contract-signing-plans/${id}/attachments/${att.id}/download`, { responseType: 'blob', }) const url = window.URL.createObjectURL(res.data as Blob) const a = document.createElement('a') a.href = url a.download = att.fileName a.click() window.URL.revokeObjectURL(url) } catch (e) { toast.error(getErrorMessage(e)) } } async function onPickFiles(e: React.ChangeEvent, dossierItemId?: string) { const files = Array.from(e.target.files ?? []) e.target.value = '' for (const f of files) { try { await upload.mutateAsync({ file: f, dossierItemId }) } catch { /* lỗi đã toast ở onError */ } } } // File "đính kèm khi duyệt" thuộc panel 3 (mục 5) — Section 4 chỉ liệt kê hồ-sơ của phiếu. // [S175 · O4] Chia 2 rổ: file GẮN VÀO một căn cứ hiện ngay dưới dòng căn cứ đó; Section 4 // chỉ còn file mức PHIẾU. 🔴 Không chia thì file gắn căn cứ hiện ở CẢ HAI chỗ — người dùng // đếm 2 lần rồi tưởng đã tải nhầm lên hai bận. const dossierAttachments = new Map() const planAttachments: KhkkAttachmentDto[] = [] for (const a of plan.attachments) { if (a.purpose === KhkkAttachmentPurpose.ApprovalAttachment) continue if (a.dossierItemId) { const bucket = dossierAttachments.get(a.dossierItemId) if (bucket) bucket.push(a) else dossierAttachments.set(a.dossierItemId, [a]) } else { planAttachments.push(a) } } // [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` / // `plan.workflowSteps` VẪN có trong DTO (panel 3 dùng) — chỉ bỏ chỗ VẼ ở đây. return ( // [S175 — anh: "Viền xanh này trong thao tác" → rồi "ý tao bỏ cái viền xanh thôi, còn bộ // khung table thuần vẫn nên giữ lại"] `.card-accent` (`index.css:112`) gói **HAI thứ**: // ① KHUNG thẻ — bo góc 0.75rem + viền 1px `#e9eef4` + nền trắng + đổ bóng → anh GIỮ // ② DẢI ACCENT DỌC 3px bên trái (`border-left`) → anh BỎ // 🔴 Lượt trước lead gỡ CẢ CLASS ⇒ mất luôn ①, panel trôi không khung. Nay dựng lại ① // bằng utility thuần, giữ đúng số đo gốc (`#e9eef4`, `rounded-xl`) để không lệch tông với // các thẻ khác còn dùng `.card-accent`. // 🔸 Bỏ luôn `hover` nhấc-thẻ của class gốc: hiệu-ứng đó thiết-kế cho KPI-card nhỏ, đặt // lên panel nội-dung cao cả màn thì nhấp-nháy vô cớ mỗi lần rê chuột.
{/* Dải 4 giai-đoạn — GĐ2 đang sáng. Cùng component với màn Duyệt NCC (không nhân bản). */}
{plan.maKeHoach ?? '(chưa cấp mã)'} {plan.peTenGoiThau ?? '—'} {/* [S175 — anh: "Đưa lên trên phiếu — NCC"] Mã phiếu PE nguồn + NCC trúng thầu dời từ mục 1 lên ĐÂY. Đây là 2 thứ người xem cần biết ngay khi mở phiếu, không phải cuộn xuống bảng mới thấy. NCC lấy DUY-NHẤT từ `lines` (liên danh N NCC ⇒ liệt kê hết). 🔴 CÒN NỢ: "số tiền CCM và PRO duyệt" anh yêu cầu CHƯA đưa lên được — `KhkkDetailDto` hiện 0 trường ngân sách (đo: `grep -c ccm khkk.ts` = 0); cần BE trả thêm. */} {plan.peMaPhieu && ( {plan.peMaPhieu} )} {[...new Set(plan.lines.map(l => l.supplierName).filter(Boolean))].map(n => ( {n} ))} {plan.approvalGroup != null && ( N{plan.approvalGroup} )} {KHKK_PHASE_LABELS[phase]}
{/* ─────────────── 1 ─────────────── */} {/* 🧊 [S175 — anh: "Bỏ hết đi"] Nút "Sửa" ĐÃ GỠ khỏi `actions`. Nó chỉ mở form 2 trường **Link hồ sơ + Ghi chú** — mà chính 2 trường đó anh cũng bảo bỏ khỏi màn ở cùng lượt. Giữ nút mở form sửa một thứ không còn hiển thị ở đâu là mâu thuẫn. 🔸 Máy KHÔNG mất: `hoSoLink`/`ghiChu` vẫn nằm trong DTO + BE (`UpdateContractSigningPlanDraftCommand`), muốn bày lại chỉ là thêm chỗ vẽ. */}
} accent="var(--color-brand-500)" head="text-brand-800" chipBg="var(--color-brand-50)" chipFg="var(--color-brand-600)" > {/* [S175 — anh chốt trên ảnh khoanh đỏ] Mục 1 RÚT GỌN còn 4 ô: · "Bỏ hết đi" → GỠ **Phòng ban · Quy trình · Hạn xử lý** 🔸 Quy trình vẫn xem được ở PANEL 3 (`KhkkWorkflowPanel` đầu khối) — gỡ ở đây là bỏ chỗ vẽ TRÙNG, không mất thông tin. · "Bỏ đi" → GỠ **Link hồ sơ · Ghi chú** khỏi ô xem (form Sửa vẫn còn, xem dưới) · "Thêm hạng mục vào đây" → +ô **Hạng mục** ngay cạnh Dự án · "Đưa lên trên phiếu" → **NCC** + mã phiếu PE dời lên dải header (xem `:361`) */}
{/* [S175 — anh: "đưa qua bên phải nhé"] Lưới 2 cột đọc theo chiều NGANG (`grid-cols-2` xếp trái→phải), nên THỨ TỰ KHAI quyết định cột. Xếp lại để **Hạng mục (dự án) nằm NGAY DƯỚI Dự án ở cột PHẢI** — hai thứ này là một cặp (dự án ⟶ gói việc của nó), tách 2 cột thì mắt phải nhảy chéo. */} {/* [S175 — anh: "hạng mục của Dự án, Duyệt NCC ấy"] Đây là HẠNG MỤC CÔNG VIỆC của dự án, lấy từ phiếu Duyệt NCC nguồn (`PE.WorkItemId`). 🔴 KHÁC hẳn `line.tenHangMuc` — cái đó là danh-mục SP-002 dùng phân loại HỢP ĐỒNG (86 dòng, chọn ở mục 2). Hai thứ trùng tên tiếng Việt nhưng khác trục; lượt trước lead bày nhầm cái SP-002 ở đây nên anh bảo "Bỏ đi". */}
{/* 🧊 [S175 — anh: "Bỏ đi"] Khối **Link hồ sơ + Ghi chú** (cả ô XEM lẫn form SỬA) ĐÃ GỠ. Lượt trước lead mới gỡ ở lưới trên nên 2 ô này VẪN HIỆN ở khối dưới — anh chụp lại đúng chỗ đó. Nay gỡ trọn cả 2 nhánh của ternary. 🔸 Máy KHÔNG mất: hoSoLink / ghiChu còn nguyên trong DTO + BE. */}
{/* ─────────────── 2 ─────────────── */} {/* [S177 — anh: "Bỏ 1 dòng đi nhé"] Bỏ hậu tố "(N dòng)" khỏi `title`: bảng ngay dưới đã có cột STT nên số dòng tự đọc được ở dòng cuối, đếm thêm trên tiêu đề là nói hai lần. (Chú thích phải nằm NGOÀI thẻ — JSX không nhận comment giữa attribute.) */}
} accent="var(--color-teal-500)" head="text-teal-700" chipBg="var(--color-teal-50)" chipFg="var(--color-teal-700)" actions={ plan.budgetFrozen ? ( // [S175 · B2 · P0] emoji 🔒 → icon lucide: emoji render khác nhau theo font hệ // điều-hành (Windows/macOS/Android ra 3 hình), và nó KHÔNG ăn `currentColor` // nên luôn lệch tông với chữ amber bên cạnh. Giá đã chốt tại thời điểm duyệt ) : undefined } > {plan.lines.length === 0 ? (
Phiếu nguồn chưa có NCC trúng thầu.
) : ( <> {/* 🧊 [S175] 2 ô TỔNG (Σ đề xuất ⟂ Σ duyệt) đã GỠ — anh xem trên màn rồi chốt "Bỏ đi". Trước đó chính anh chọn "hiện 2 tổng riêng" khi em hỏi; nhìn thật thì thừa. Giữ 1 dòng vết để lần sau khỏi ai dựng lại tưởng là thiếu. */} {/* [S175 · B2 · P0] `table-fixed` + `` + `min-w-[880px]`: bảng co giãn tự-do trước đây để cột tiền nhảy bề rộng theo độ dài tên NCC ⇒ 3 cột số không thẳng hàng giữa các phiếu. Chốt bề rộng rồi thì hẹp màn hình sẽ CUỘN NGANG (đúng ý đồ của `overflow-x-auto` bọc ngoài) thay vì bóp chữ. */}
{/* [S177] 3 → 4 cột (+STT). Bề rộng lấy 6% từ cột hạng mục, KHÔNG đụng 2 cột bên phải: cột Giá phải giữ nguyên bề rộng thì số mới còn thẳng hàng giữa các phiếu — đó chính là lý do `table-fixed` được chốt ở S175. */} {plan.lines.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. */} ))} {/* [S175 — anh: "chọn hạng mục tiền → thành tổng"] TỔNG = Σ giá các dòng. 🔴 KHÔNG có cột "giá tham chiếu" / "giá duyệt" nữa — anh chốt "ko cần so sánh j với số duyệt PRO hay CCM j cả". Dữ-liệu 2 cột đó VẪN nằm trong DTO (`peReferenceAmount` / `approvedAmount`), chỉ bỏ chỗ vẽ; `peReferenceAmount` còn được dùng làm DẤU phân biệt dòng-gốc ⟂ dòng-thêm-tay ở nút xoá trên. */} {/* [S177] `colSpan={2}` nuốt cột STT mới — thiếu nó thì chữ "Tổng" tụt sang ô 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. */}
STT Hạng mục Giá
{i + 1} {isDraft && !l.contractId ? ( <> {/* Ghi chú xuống DÒNG PHỤ thay vì chiếm 1 cột — anh chốt bảng chỉ còn "hạng mục + giá", nhưng ghi chú thì anh vẫn muốn nhập được. */} { const v = e.target.value.trim() if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v }) }} className="mt-1 h-7 w-full rounded border border-transparent bg-transparent px-2 text-[11px] text-slate-500 hover:border-slate-200 focus-visible:border-brand-500 focus-visible:bg-white focus-visible:outline-none" /> ) : ( <> {/* [S177 — anh: "mã hạng mục (Dự án) - mã hạng mục chi tiết"] Mã đứng TRÊN tên: mã ngắn + đều nên mắt quét dọc được cả cột, còn tên thì dài ngắn khác nhau. `font-mono` để các mã thẳng cột. Vắng cả hai mã ⇒ `joinMaHangMuc` trả null ⇒ không vẽ dòng này. */} {joinMaHangMuc(plan.peWorkItemCode, l.maHangMuc) && (
{joinMaHangMuc(plan.peWorkItemCode, l.maHangMuc)}
)}
{orDash(l.tenHangMuc)}
{l.note &&
{l.note}
} )}
{isDraft && !l.contractId ? ( { 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 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)} {isDraft && !l.contractId && l.peReferenceAmount === 0 && ( )}
Tổng {formatVnd(plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
{/* [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 && (
Thêm hạng mục cho: {[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => ( ))}
)} {addFor && (
Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'}
)} )} {/* 🧊 [S175] 3 dòng chú-thích dưới bảng ĐÃ GỠ — anh: "Bỏ mấy dòng này đi". Nội dung không mất: ràng-buộc "phải gán hạng mục mới trình duyệt được" vẫn do submit-guard BE chặn thật, và ô Hạng mục ở mục 1 đã đếm "còn N dòng chưa gán". */}
{/* ─────── 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. */}
} accent="var(--color-teal-500)" head="text-teal-700" chipBg="var(--color-teal-50)" chipFg="var(--color-teal-700)" count={mergedRows.length} actions={ readOnly || !isDraft ? undefined : ( ) } > {/* [S175 — anh chốt] Chỗ tải file bê ĐÚNG khuôn Duyệt NCC (`PeDetailTabs.tsx:3527-3531` + `:3601-3608`): dòng chữ mô-tả ở TRÊN, nút viền ĐỨT nền brand-nhạt ở DƯỚI danh sách — thay cho nút đặc nằm ở góc header. Nút viền đứt đọc ra là "chỗ thả thêm vào", nút đặc ở header đọc ra là "hành động của cả khối"; đây là cái thứ nhất. */} {/* 🧊 [S175 — anh: "Bỏ hết mấy chữ thừa này đi"] Dòng mô-tả "File căn cứ hồ sơ / shopdrawing của kế hoạch…" ĐÃ GỠ. Nút ⬆ ở mỗi dòng căn cứ đã tự nói nó làm gì (có `title`), không cần một câu hướng-dẫn nằm chình ình trên đầu khối. */} {mergedRows.length === 0 ? (
Chưa có căn cứ hồ sơ hay file đính kèm nào.
) : (
{mergedRows.map((r) => ( r.rk === 'file' ? (
{r.att.fileName} {formatSize(r.att.fileSize)} {r.item ? (DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ') : 'Hồ sơ phiếu'} {r.item && ( {r.item.name} )} {formatDateTime(r.att.createdAt)} {isPreviewable(r.att.fileName) && ( )} {!readOnly && ( )}
) : ( /* 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. */
{r.item.name} {DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ'} {DOSSIER_STATUS_LABELS[r.item.status]} chưa có file {isDraft && ( <> )}
) ))}
)} {/* Nút tải lên — viền ĐỨT, đặt DƯỚI danh sách, y khuôn PE `:3601-3608`. `accept` bê nguyên danh-sách của PE: chặn ngay ở hộp chọn file đỡ được vòng upload-rồi-mới-biết-sai-định-dạng. */} {!readOnly && ( )} {previewAtt && ( setPreviewAtt(null)} /> )}
{/* Dialog thêm/sửa căn cứ */} setItemForm(null)} title={itemForm?.id ? 'Sửa căn cứ hồ sơ' : 'Thêm căn cứ hồ sơ'}> {itemForm && (
setItemForm({ ...itemForm, name: e.target.value })} maxLength={300} placeholder="vd. Mẫu thép D10 Hoà Phát" />
setItemForm({ ...itemForm, tvgsName: e.target.value })} maxLength={200} />