// [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 KhkkLineDto, 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 = <span className="text-slate-300">—</span>

/** 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 `<mã hạng mục dự án> - <mã hạng mục chi tiết>` 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 (
    <section className="px-5 py-4">
      <header className="mb-3 flex items-center gap-2">
        <span
          className="icon-chip"
          style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: chipBg, ['--chip-fg' as string]: chipFg }}
          aria-hidden
        >
          {icon}
        </span>
        <h3 className={cn('text-sm font-semibold tracking-tight', head)} style={{ ['--accent' as string]: accent }}>
          {title}
        </h3>
        {count != null && (
          <span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
            {count}
          </span>
        )}
        {actions && <span className="ml-auto">{actions}</span>}
      </header>
      {children}
    </section>
  )
}

function Field({
  label, value, mono, full, children,
}: {
  label: string
  value?: ReactNode
  mono?: boolean
  full?: boolean
  children?: ReactNode
}) {
  const empty = value == null || value === ''
  return (
    <div className={cn('min-w-0 text-sm', full && 'sm:col-span-2')}>
      <div className="text-[11px] font-semibold uppercase tracking-wide text-brand-600">{label}</div>
      {/* [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. */}
      <div className={cn('mt-0.5 break-words text-sm', empty && !children ? 'text-slate-300' : 'font-medium text-slate-900', mono && !empty && 'font-mono')}>
        {children ?? (empty ? '—' : value)}
      </div>
    </div>
  )
}

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<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).
  // [S177] Chỉ còn `supplierId`: `supplierName` đi cùng khi dòng nhập có chú-thích "Thêm cho:
  // <NCC>"; anh chốt gỡ chú-thích đó ⇒ giữ lại field là **gán mà không ai đọc**. Tên NCC vẫn
  // đọc được từ chính dòng bấm `+` (`l.supplierName`, dùng cho `title`/`aria-label`).
  const [addFor, setAddFor] = useState<{ supplierId: string } | null>(null)
  const [addCatalogId, setAddCatalogId] = useState('')
  const [addAmount, setAddAmount] = useState('')
  // 🧊 [S178] `addNote` ĐÃ GỠ cùng ô nhập Ghi chú — giữ lại sẽ là biến gán-mà-không-đọc,
  // đúng lớp vừa dọn ở đợt-5 (`addFor.supplierName`).

  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?.()
  }

  // ─── [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({
    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({
    // 🧊 [S178] Bỏ `note` khỏi type + body: sau khi gỡ ô nhập thì 0 caller nào truyền nữa.
    // 🔴 KHÔNG đổi hành vi — `note: undefined` vốn đã bị JSON bỏ qua, và BE gác
    // `if (request.Note != null)` nên vắng mặt = BỎ QUA, không phải ghi null. Trường `Note`
    // của BE giữ NGUYÊN (máy rộng), chỉ FE thôi gửi.
    mutationFn: async (p: { lineId: string; proposedAmount?: number }) =>
      api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, {
        proposedAmount: p.proposedAmount,
      }),
    onSuccess: () => { toast.success('Đã lưu dòng'); invalidate() },
    onError: (e) => toast.error(getErrorMessage(e)),
  })

  const addLine = useMutation({
    // 🧊 [S178] Bỏ `note` — cùng lý lẽ với `saveLineField` ngay trên.
    mutationFn: async (p: { supplierId: string; catalogEntryId: string; proposedAmount: number }) =>
      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)),
  })

  // ─── [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() },
    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<HTMLInputElement>, 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<string, KhkkAttachmentDto[]>()
  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:118`) 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.
    <div className="overflow-hidden rounded-xl border border-[#e9eef4] bg-white shadow-sm">
      {/* 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). */}
      <PePipelineStrip current={2} />

      <div className="flex flex-wrap items-center gap-2 border-b border-slate-200 px-5 py-3">
        <span className="font-mono text-sm font-semibold text-brand-800">{plan.maKeHoach ?? '(chưa cấp mã)'}</span>
        <span className="min-w-0 flex-1 truncate text-sm text-slate-700">{plan.peTenGoiThau ?? '—'}</span>
        {/* [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 && (
          <span
            className="inline-flex items-center rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-600"
            title="Phiếu Duyệt NCC nguồn"
          >
            {plan.peMaPhieu}
          </span>
        )}
        {[...new Set(plan.lines.map(l => l.supplierName).filter(Boolean))].map(n => (
          <span
            key={n as string}
            className="inline-flex max-w-[16rem] items-center truncate rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700"
            title={`NCC trúng thầu: ${n}`}
          >
            {n}
          </span>
        ))}
        {plan.approvalGroup != null && (
          <span
            className="inline-flex items-center rounded-md border border-violet-300 bg-violet-100 px-2 py-0.5 text-[11px] font-semibold text-violet-800"
            title={KHKK_APPROVAL_GROUP_LABELS[plan.approvalGroup] ?? `Nhóm ${plan.approvalGroup}`}
          >
            N{plan.approvalGroup}
          </span>
        )}
        <span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium', KHKK_PHASE_BADGE[phase])}>
          {KHKK_PHASE_LABELS[phase]}
        </span>
      </div>

      <div className="divide-y divide-slate-200">
        {/* ─────────────── 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ẽ. */}
        <Section
          title="1. Thông tin kế hoạch"
          icon={<FileCheck className="h-4 w-4" />}
          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`) */}
          <div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
            {/* [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. */}
            <Field label="Mã kế hoạch" value={plan.maKeHoach ?? '—'} mono />
            <Field label="Dự án" value={plan.projectName ?? '—'} />
            <Field label="Người soạn" value={plan.drafterFullName ?? '—'} />
            {/* 🧊 [S177 — anh: "Làm header dưới đây"] Ô **Hạng mục (dự án)** ĐÃ DỜI xuống làm
                header của mục 2 (xem ngay dưới). Nó là gói việc mà CẢ BẢNG hạng mục thuộc về,
                nên đứng đầu bảng thì đúng vai hơn là nằm lẫn giữa các ô meta ở đây.
                🔸 Chú thích S175 cũ ("xếp NGAY DƯỚI Dự án ở cột PHẢI") hết hiệu lực theo. */}
            <Field label="Ngày tạo" value={formatDateTime(plan.createdAt)} />
          </div>

          {/* 🧊 [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. */}
        </Section>

        {/* ─────────────── 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.) */}
        <Section
          title="2. Hạng mục"
          icon={<Users className="h-4 w-4" />}
          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.
              <span className="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-100 px-2 py-0.5 text-[11px] font-semibold normal-case tracking-normal text-amber-800">
                <Lock className="h-3 w-3" aria-hidden /> Giá đã chốt tại thời điểm duyệt
              </span>
            ) : undefined
          }
        >
          {/* [S177 — anh: "Làm header dưới đây"] HẠNG MỤC (DỰ ÁN) = gói việc mà CẢ bảng dưới
              thuộc về, dời từ mục 1 xuống đây làm header của mục 2.
              🔴 ĐẶT NGOÀI ternary `lines.length === 0`: phiếu chưa có NCC trúng thầu thì vẫn
              phải biết mình đang lập kế hoạch cho gói việc nào — nhét vào nhánh có-dòng là
              đúng ca "vắng-mặt trông giống ổn" (màn rỗng nhìn vẫn gọn nên không ai kêu). */}
          <div className="mb-3 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 border-b border-slate-200 pb-2">
            <span className="label-eyebrow text-slate-500">Hạng mục (dự án)</span>
            {/* [S177 — anh: "Tô đậm" (mã) + "Bỏ đi" (tên)] Header giờ CHỈ còn MÃ, in đậm.
                🔴 Fallback về TÊN khi thiếu mã: `peWorkItemCode` null khi PE không gắn hạng mục
                (`WorkItemId` là loose-Guid, Mig 49 — được phép rỗng). Bỏ tên VÔ ĐIỀU KIỆN thì
                đúng ca đó header còn trơ mỗi chữ "Hạng mục (dự án)" — một nhãn không có giá trị
                đi kèm, trông như lỗi render chứ không như "phiếu này chưa gắn hạng mục". */}
            <span className="font-mono text-sm font-bold text-slate-800">
              {plan.peWorkItemCode ?? plan.peWorkItemName ?? '—'}
            </span>
          </div>

          {plan.lines.length === 0 ? (
            <div className="text-sm italic text-slate-400">Phiếu nguồn chưa có NCC trúng thầu.</div>
          ) : (
            <>
            {/* 🧊 [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` + `<colgroup>` + `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ữ. */}
            <div className="overflow-x-auto">
              <table className="w-full min-w-[880px] table-fixed text-xs">
                {/* [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. */}
                <colgroup>
                  <col className="w-[6%]" />
                  <col className="w-[46%]" />
                  <col className="w-[32%]" />
                  <col className="w-[16%]" />
                </colgroup>
                <thead className="border-b border-slate-200 bg-slate-50/70">
                  <tr>
                    <th className="label-eyebrow px-3 py-2 text-right">STT</th>
                    <th className="label-eyebrow px-3 py-2 text-left">Hạng mục</th>
                    <th className="label-eyebrow px-3 py-2 text-right">Giá</th>
                    <th className="label-eyebrow px-3 py-2 text-right"></th>
                  </tr>
                </thead>
                <tbody>
                  {/* [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) => (
                    <tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
                      {/* [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. */}
                      <td className="px-3 py-2 text-right align-top tabular-nums text-slate-400">{i + 1}</td>
                      <td className="px-3 py-2 text-slate-700">
                        {isDraft && !l.contractId ? (
                          // 🧊 [S178 — anh: "bỏ luôn chỗ ghi chú luôn"] Ô nhập Ghi chú của DÒNG
                          // ĐÃ CÓ đã GỠ. Dòng nháp nay chỉ còn đúng 2 ô anh cần: hạng mục + giá.
                          // 🔴 DỮ LIỆU KHÔNG MẤT: `Note` vẫn nguyên trong DB, và sửa giá KHÔNG
                          // xoá nó — `UpdateContractSigningPlanLineCommandHandler` gác
                          // `if (request.Note != null)` nên `note` vắng mặt là BỎ QUA, không phải
                          // ghi null. Ghi chú cũ vẫn hiện ở nhánh chỉ-đọc bên dưới (`l.note`).
                          // ⇒ Đây là "máy rộng, UI hẹp": BE giữ trường, UI thôi bày.
                          <select
                            value={l.catalogEntryId ?? ''}
                            onChange={(e) => e.target.value && assignLine.mutate({ lineId: l.id, catalogEntryId: e.target.value })}
                            disabled={assignLine.isPending}
                            aria-label={`Chọn hạng mục cho dòng ${l.id}`}
                            // [S177 — anh: "Các cái mẫu bỏ đi nhé"] DÒNG ĐÃ CÓ = đọc như DỮ
                            // LIỆU, không như form: viền trong suốt + nền trong suốt, khung chỉ
                            // hiện khi rê chuột / vào ô — cùng ngôn ngữ với ô giá bên phải.
                            // 🔴 Chỉ đổi VẺ, KHÔNG đổi hành vi: vẫn `<select>` thật, vẫn sửa
                            // được, vẫn tự lưu. Mũi tên dropdown của native select vẫn hiện nên
                            // không mất tín-hiệu "ô này bấm được".
                            className="h-8 w-full rounded border border-transparent bg-transparent px-2 text-xs text-slate-700 hover:border-slate-200 focus-visible:border-brand-500 focus-visible:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
                          >
                            <option value="">— Chọn hạng mục —</option>
                            {l.catalogEntryId && !(catalogOptions.data ?? []).some((c) => c.id === l.catalogEntryId) && (
                              <option value={l.catalogEntryId}>{l.tenHangMuc ?? '(hạng mục đã gán)'}</option>
                            )}
                            {(catalogOptions.data ?? []).map((c) => (
                              <option key={c.id} value={c.id}>{c.code} — {c.tenVi}</option>
                            ))}
                          </select>
                        ) : (
                          <>
                            {/* [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) && (
                              <div className="font-mono text-[11px] font-medium text-slate-500">
                                {joinMaHangMuc(plan.peWorkItemCode, l.maHangMuc)}
                              </div>
                            )}
                            <div>{orDash(l.tenHangMuc)}</div>
                            {l.note && <div className="mt-0.5 text-[11px] text-slate-500">{l.note}</div>}
                          </>
                        )}
                      </td>
                      <td className="px-3 py-2 text-right align-top tabular-nums text-brand-800">
                        {isDraft && !l.contractId ? (
                          <input
                            type="number"
                            min={0}
                            step={1000}
                            defaultValue={l.proposedAmount}
                            disabled={saveLineField.isPending}
                            aria-label="Giá của hạng mục"
                            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 })
                            }}
                            // [S177] Cùng lý lẽ với ô hạng mục ngay trên — bỏ khung ở dòng ĐÃ CÓ.
                            className="h-8 w-full rounded border border-transparent bg-transparent px-2 text-right text-xs tabular-nums text-brand-800 hover:border-slate-200 focus-visible:border-brand-500 focus-visible:bg-white 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 align-top">
                        {/* [S177 — anh: "Thêm dấu + thêm ở đây"] Nút THÊM về đúng cột hành-động
                            của TỪNG DÒNG, thay khối "Thêm hạng mục cho: [NCC…]" (anh: "bỏ đi").
                            🔴 Vì sao gọn hơn hẳn: nút ở dòng nào thì BIẾT SẴN NCC của dòng đó
                            (`l.supplierId`), nên không phải bày danh sách NCC cho người dùng chọn
                            lại thứ mà vị trí bấm đã nói rồi. Khối cũ tồn tại chỉ vì nó đứng NGOÀI
                            mọi dòng nên buộc phải hỏi "cho NCC nào". */}
                        <div className="flex items-center justify-end gap-0.5">
                          {isDraft && (
                            <button
                              type="button"
                              onClick={() => {
                                setAddFor({ supplierId: l.supplierId })
                                setAddCatalogId(''); setAddAmount('')
                              }}
                              title={`Thêm hạng mục cho ${l.supplierName ?? 'NCC này'}`}
                              aria-label={`Thêm hạng mục cho ${l.supplierName ?? 'NCC này'}`}
                              className="rounded px-1 py-1 text-brand-600 hover:bg-brand-50"
                            >
                              <Plus className="h-3.5 w-3.5" />
                            </button>
                          )}
                          {isDraft && !l.contractId && 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="rounded px-1 py-1 text-red-500 hover:bg-red-50"
                            >
                              <Trash2 className="h-3.5 w-3.5" />
                            </button>
                          )}
                        </div>
                      </td>
                    </tr>
                  ))}
                  {/* [S177 — anh: "Cho 2 cái này về làm một đc ko?"] Form thêm giờ là MỘT DÒNG
                      của chính bảng, dùng ĐÚNG lưới 4 cột — không còn panel rời bên dưới.
                      🔴 Vì sao KHÔNG làm kiểu "bấm + đẻ ngay dòng trống rồi nhập tại chỗ" (đúng
                      lối inline của các dòng khác): `AddContractSigningPlanLineCommand:759` khai
                      `CatalogEntryId` là `Guid` **không nullable** + validator `NotEmpty()`, vì
                      unique index (Plan, Supplier, CatalogEntry) **CỐ Ý không lọc NULL**
                      (`ContractSigningPlanLineConfiguration.cs:35-38`) — trong UNIQUE của SQL
                      Server thì NULL == NULL, nên dòng trống thứ hai của CÙNG một NCC sẽ đụng
                      ngay dòng gốc của chính nó. Nới index để lách = "thả trôi vô hạn dòng
                      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 && 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".
                    <tr className="border-b border-slate-100 hover:bg-slate-50/60">
                      <td className="px-3 py-2 text-right align-top text-slate-400">
                        <Plus className="ml-auto h-3.5 w-3.5" />
                      </td>
                      <td className="px-3 py-2 align-top">
                        {/* 🧊 [S177 — anh chọn "Bỏ dòng «Thêm cho: NCC» → mọi thứ tự thẳng hàng"]
                            Dòng chú-thích tên NCC ĐÃ GỠ. Nó đẩy `select` xuống 1 dòng nên ô hạng
                            mục lệch hẳn với ô giá + nút bên phải; gỡ đi thì cả dòng nhập thẳng
                            hàng y như dòng thật — đó chính là "đưa lên đây".
                            🔴 CÁI MẤT, khai thẳng: bảng KHÔNG có cột NCC ⇒ phiếu nhiều NCC thì
                            dòng nhập không còn tự nói nó thuộc nhà cung cấp nào; người dùng phải
                            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) && (
                          <select
                            value={effectiveAddFor.supplierId}
                            onChange={(e) => 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]) => (
                              <option key={sid} value={sid}>{sname ?? '(NCC không tên)'}</option>
                            ))}
                          </select>
                        )}
                        <select
                          value={addCatalogId}
                          onChange={(e) => setAddCatalogId(e.target.value)}
                          aria-label="Hạng mục của dòng mới"
                          className="h-8 w-full 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"
                        >
                          <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>
                        {/* 🧊 [S178 — anh: "bỏ luôn chỗ ghi chú luôn"] Ô Ghi chú của DÒNG THÊM đã
                            GỠ, cùng lượt với ô Ghi chú của dòng đã có ở trên — giữ hai chỗ CÙNG
                            hình dạng vẫn là luật ở đây, chỉ là hình dạng chung nay gọn hơn.
                            🔴 Thao tác anh chốt còn đúng 3 nhịp: chọn hạng mục → nhập giá → Thêm. */}
                      </td>
                      <td className="px-3 py-2 align-top">
                        <input
                          type="number" min={0} step={1000}
                          value={addAmount}
                          onChange={(e) => setAddAmount(e.target.value)}
                          placeholder="0"
                          aria-label="Giá của dòng mới"
                          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"
                        />
                      </td>
                      <td className="px-3 py-2 align-top">
                        <div className="flex items-center justify-end gap-1">
                          <Button
                            size="sm"
                            disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0
                              || addLine.isPending || adoptSeedLine.isPending}
                            // [S178] 2 đường, chọn theo việc NCC này còn dòng gốc chưa gán hay không:
                            //   còn  → GÁN vào dòng gốc (PUT) ⇒ hết dòng NULL ⇒ submit-guard thông
                            //   hết  → đẻ dòng mới (POST) như cũ
                            // Tìm lại dòng gốc ngay tại đây (không cache) vì `plan.lines` đã tươi
                            // sau mỗi `invalidate()`; giữ biến ngoài dễ trỏ vào dòng vừa bị gán.
                            onClick={() => {
                              const seed = plan.lines.find(
                                (x) => isUnassignedSeed(x) && x.supplierId === effectiveAddFor.supplierId)
                              if (seed) {
                                adoptSeedLine.mutate({
                                  lineId: seed.id,
                                  catalogEntryId: addCatalogId,
                                  proposedAmount: Number(addAmount),
                                })
                              } else {
                                addLine.mutate({
                                  supplierId: effectiveAddFor.supplierId,
                                  catalogEntryId: addCatalogId,
                                  proposedAmount: Number(addAmount),
                                })
                              }
                            }}
                          >
                            {addLine.isPending || adoptSeedLine.isPending ? 'Đang thêm…' : 'Thêm'}
                          </Button>
                          {/* [S178] "Hủy" CHỈ hiện khi dòng nhập do người dùng MỞ (bấm `+`).
                              Lúc nó TỰ MỞ vì còn NCC chưa với tới được thì Hủy là nút chết —
                              bấm xong `effectiveAddFor` tính lại và mở y như cũ. Bày một nút
                              không làm được gì còn tệ hơn không bày. */}
                          {addFor && (
                            <Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
                          )}
                        </div>
                      </td>
                    </tr>
                  )}
                  {/* 🧊 [S177 — anh: "bỏ đi"] Khối "Thêm hạng mục cho: [NCC…]" ĐÃ GỠ HẲN.
                      Vòng đời đủ 3 nấc trong CÙNG lượt S177, giữ vết để khỏi ai dựng lại:
                        (1) S175 đặt DƯỚI bảng → (2) anh: "+ thêm hạng mục ở đây" ⇒ dời vào
                        `tbody` → (3) anh nhìn thật rồi chốt "bỏ đi", thay bằng nút **+** ở cột
                        hành-động của TỪNG DÒNG (xem `<td>` hành-động phía trên).
                      🔴 Chức năng KHÔNG mất: nút + trên dòng gọi đúng `setAddFor` cũ, và còn
                      biết sẵn NCC nên bỏ được bước chọn NCC. Panel nhập `addFor` giữ nguyên. */}
                </tbody>
                {/* [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. */}
                <tfoot>
                  <tr className="border-t-2 border-slate-300 bg-slate-50">
                    {/* [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. */}
                    <td colSpan={2} className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-slate-600">Tổng</td>
                    <td className="px-3 py-2 text-right text-sm font-bold tabular-nums text-brand-800">
                      {/* [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))}
                    </td>
                    <td />
                  </tr>
                </tfoot>
              </table>
            </div>

            {/* 🧊 [S177 — anh: "+ thêm hạng mục ở đây"] Khối "Thêm hạng mục cho:" ĐÃ DỜI vào
                trong `tbody` (dòng cuối, trên TỔNG). Lý lẽ S175 vẫn đúng và đi theo lên trên:
                1 NCC được N hạng mục từ Mig 71, và CHỈ liệt kê NCC ĐÃ CÓ trên phiếu — đẻ NCC
                mới ở đây là ký với nhà thầu chưa ai chọn (BE cũng chặn, 409).
                🔴 Giữ 1 dòng vết vì đây là chỗ người sau sẽ tìm khi hỏi "nút thêm đâu rồi". */}

            {/* 🧊 [S177 — anh: "Cho 2 cái này về làm một đc ko?"] Panel "Thêm hạng mục" rời
                ĐÃ GỠ — nội dung của nó nay là 1 DÒNG trong `tbody` (xem trên), dùng chung lưới
                4 cột với các dòng thật. Giữ vết vì đây là chỗ người sau tìm khi hỏi form đâu.
                🔴 Đúng 3 trường cũ (hạng mục / giá / ghi chú) đi theo nguyên vẹn — gộp là gộp
                CHỖ VẼ, không cắt bớt trường nào. */}
            </>
          )}
          {/* 🧊 [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". */}
        </Section>

        {/* ─────── 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. 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={mergedRows.length}
          actions={
            readOnly || !isDraft ? undefined : (
              <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>
            )
          }
        >
          {/* [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 ? (
            <div className="text-sm italic text-slate-400">Chưa có căn cứ hồ sơ hay file đính kèm nào.</div>
          ) : (
            <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 "${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"
                      >
                        <Trash2 className="h-3 w-3" />
                      </button>
                    )}
                  </div>
                ) : (
                  /* 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>
                )
              ))}
            </div>
          )}
          {/* 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 && (
            <label className={cn(
              'mt-2 inline-flex cursor-pointer items-center gap-1.5 rounded border border-dashed border-brand-300',
              'bg-brand-50/50 px-3 py-2 text-xs font-medium text-brand-700',
              'hover:border-brand-500 hover:bg-brand-50',
              upload.isPending && 'pointer-events-none opacity-50',
            )}>
              <Upload className="h-3.5 w-3.5" />
              {upload.isPending ? 'Đang tải…' : '+ Tải lên hồ sơ'}
              <input
                type="file"
                multiple
                accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
                className="hidden"
                onChange={(e) => onPickFiles(e)}
              />
            </label>
          )}
          {previewAtt && (
            <AttachmentPreviewDialog
              open
              basePath="/contract-signing-plans"
              evaluationId={plan.id}
              attachmentId={previewAtt.id}
              fileName={previewAtt.fileName}
              onClose={() => setPreviewAtt(null)}
            />
          )}
        </Section>
      </div>

      {/* Dialog thêm/sửa căn cứ */}
      <Dialog open={!!itemForm} onClose={() => setItemForm(null)} title={itemForm?.id ? 'Sửa căn cứ hồ sơ' : 'Thêm căn cứ hồ sơ'}>
        {itemForm && (
          <div className="space-y-3">
            <div>
              <Label htmlFor="item-kind">Loại căn cứ</Label>
              <select
                id="item-kind"
                value={itemForm.kind}
                onChange={(e) => setItemForm({ ...itemForm, kind: Number(e.target.value) as DossierItemKindValue })}
                className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
              >
                {Object.entries(DOSSIER_KIND_LABELS).map(([v, label]) => (
                  <option key={v} value={v}>{label}</option>
                ))}
              </select>
            </div>
            <div>
              <Label htmlFor="item-name">Tên căn cứ <span className="text-red-500">*</span></Label>
              <Input id="item-name" value={itemForm.name} onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })} maxLength={300} placeholder="vd. Mẫu thép D10 Hoà Phát" />
            </div>
            <div>
              <Label htmlFor="item-status">Trạng thái</Label>
              <select
                id="item-status"
                value={itemForm.status}
                onChange={(e) => setItemForm({ ...itemForm, status: Number(e.target.value) as DossierItemStatusValue })}
                className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
              >
                {Object.entries(DOSSIER_STATUS_LABELS).map(([v, label]) => (
                  <option key={v} value={v}>{label}</option>
                ))}
              </select>
            </div>
            <div>
              <Label htmlFor="item-tvgs">Tên TVGS xử lý</Label>
              <Input id="item-tvgs" value={itemForm.tvgsName ?? ''} onChange={(e) => setItemForm({ ...itemForm, tvgsName: e.target.value })} maxLength={200} />
            </div>
            <div>
              <Label htmlFor="item-note">Ghi chú</Label>
              <Textarea id="item-note" value={itemForm.note ?? ''} onChange={(e) => setItemForm({ ...itemForm, note: e.target.value })} maxLength={1000} rows={3} />
            </div>
            <div className="flex justify-end gap-2 pt-2">
              <Button variant="outline" onClick={() => setItemForm(null)}>Huỷ</Button>
              <Button onClick={() => upsertItem.mutate(itemForm)} disabled={upsertItem.isPending || !itemForm.name.trim()}>
                {upsertItem.isPending ? 'Đang lưu...' : 'Lưu'}
              </Button>
            </div>
          </div>
        )}
      </Dialog>

      {/* [S175 — anh: "phía dưới thêm các nút thao tác"] THANH NÚT ĐÁY, khuôn PE workspace.
          Gom về ĐÂY những nút anh bảo gỡ khỏi chỗ khác: "Gửi duyệt" + "Xóa phiếu" (trước ở
          khối HÀNH ĐỘNG panel 3). Panel 3 từ nay chỉ còn **Duyệt / Trả lại** — đúng câu anh
          chốt "chỗ menu đang duyệt → Duyệt/Trả lại vậy thôi".
          🔴 `sticky bottom-0`: phiếu dài, nút nằm cuối trang thì phải cuộn hết mới thấy —
          dính đáy là để lúc nào cũng với tới được.
          🔸 KHÔNG có nút "Lưu" rời: mọi ô ở màn này lưu NGAY khi rời ô (hạng mục / giá / ghi
          chú / file). Thêm một nút "Lưu" không làm gì là hứa hão — nói thẳng bằng dòng chữ
          bên cạnh thay vì vẽ nút giả. */}
      {!readOnly && (
        <div className="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-slate-200 bg-white/95 px-5 py-3 backdrop-blur">
          <button
            type="button"
            onClick={() => {
              if (window.confirm(`Xóa phiếu ${plan.maKeHoach ?? ''}? Phiếu chuyển sang mục "Đã xóa".`)) deletePlan.mutate()
            }}
            disabled={deletePlan.isPending}
            className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
          >
            <Trash2 className="h-3.5 w-3.5" />
            {deletePlan.isPending ? 'Đang xóa…' : 'Xóa phiếu'}
          </button>

          <span className="min-w-0 flex-1 text-[12px] text-emerald-700">
            ✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
          </span>

          {(() => {
            // Rào HIỂN THỊ mirror submit-guard BE: chưa pin quy trình, hoặc còn dòng chưa gán
            // hạng mục ⇒ BE sẽ chặn. Nói TRƯỚC lý do thay vì để bấm rồi ăn 409.
            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.'
                : 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 (
              <button
                type="button"
                onClick={() => { if (!blocked) submitPlan.mutate() }}
                disabled={blocked || submitPlan.isPending}
                title={why ?? 'Gửi phiếu vào quy trình duyệt'}
                className={cn(
                  'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-semibold text-white transition',
                  blocked ? 'cursor-not-allowed bg-slate-300' : 'bg-brand-600 hover:bg-brand-700',
                  submitPlan.isPending && 'opacity-50',
                )}
              >
                {submitPlan.isPending ? 'Đang gửi…' : 'Lưu & Gửi duyệt →'}
              </button>
            )
          })()}
        </div>
      )}
    </div>
  )
}
