Files
solution-erp/fe-admin/src/components/khkk/KhkkDetailContent.tsx
pqhuy1987 530af22910
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 7m2s
[CLAUDE] Contract: KHKK man duyet — bo "(N dong)" + cot STT + ma hang muc (Mig 72)
Anh giao qua anh prod man duyet KHKK, 2 diem khoanh do:
 (1) "Bo 1 dong di nhe" -> go hau to "(N dong)" khoi title Section 2.
 (2) "Hien thi stt va ma hang muc (Du an) - ma hang muc chi tiet"
     -> bang 3->4 cot (+STT cot rieng, anh chot), cot HANG MUC them dong
        ma "<ma du an> - <ma chi tiet>" nam TREN ten.

Mig 72 AddMaHangMucToContractSigningPlanLines (anh chot DENORM co migration):
 - +cot MaHangMuc nvarchar(20) = BANG nguon ContractCatalogEntry.Code.
 - backfill Sql() idempotent (WHERE MaHangMuc IS NULL), COY KHONG loc IsDeleted:
   hang muc xoa mem SAU ngay lap phieu thi dong phieu van phai giu duoc ma —
   do chinh la ly do cot nay la denorm. Loc di la bo trong dung dong can cuu nhat.
 - ten bang doi chieu voi ToTable() cua CA HAI configuration truoc khi viet
   (Mig 60 tung backfill soi NHAM BANG, chay sach, phai de Mig 61 va — gotcha #78/#79).

PeWorkItemCode KHONG denorm — noi join WorkItems san co o Features.cs:929, 0 cot moi.
Chup lai vao KHKK la chup mot thu von da la con tro loose-Guid qua PE (Mig 49).

3 write-site denorm sua DU: :527 moi null · :695 gan lai · :805 tao moi.
Sot 1 cho = de dong co ten ma khong co ma, chi lo ra o luong it ai bam.

FE 2 app SHA IDENTICAL (verify identical @HEAD TRUOC khi copy de).
Build BE 0 warning 0 error · 2 app build sach · 644/644 test pass.
Lint 42 van de = NO CO SAN, 0 cai o 2 file luot nay dung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:47:08 +07:00

979 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// [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 = <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).
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<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: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.
<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 ?? '—'} />
{/* [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". */}
<Field label="Hạng mục (dự án)" value={plan.peWorkItemName ?? '—'} />
<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
}
>
{plan.lines.length === 0 ? (
<div className="text-sm italic text-slate-400">Phiếu nguồn chưa 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>
{plan.lines.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 ? (
<>
<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}`}
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>
{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>
{/* 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. */}
<input
type="text"
maxLength={1000}
defaultValue={l.note ?? ''}
placeholder="Ghi chú (tùy chọn)…"
disabled={saveLineField.isPending}
aria-label="Ghi chú dòng"
onBlur={(e) => {
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) && (
<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 })
}}
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)}
</td>
<td className="px-3 py-2 text-right align-top">
{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>
)}
</td>
</tr>
))}
</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">
{formatVnd(plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
{/* [S175 — anh chốt] THÊM HẠNG MỤC cho 1 NCC. 1 NCC được N hạng mục: máy đã cho
phép từ Mig 71 (unique 3 cột `Plan × Supplier × CatalogEntry`), lượt này chỉ
bung UI — đúng khuôn "máy rộng, UI hẹp" anh chốt S164.
🔴 CHỈ liệt kê NCC ĐÃ CÓ trên phiếu: NCC đến từ winner của phiếu Duyệt NCC;
đẻ NCC mới ở đây là ký với nhà thầu chưa ai chọn (BE cũng chặn, 409). */}
{isDraft && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="text-[11px] text-slate-500">Thêm hạng mục cho:</span>
{[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => (
<button
key={l.supplierId}
type="button"
onClick={() => {
setAddFor({ supplierId: l.supplierId, supplierName: l.supplierName })
setAddCatalogId(''); setAddAmount(''); setAddNote('')
}}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50"
>
<Plus className="h-3 w-3" /> {l.supplierName ?? '(không rõ NCC)'}
</button>
))}
</div>
)}
{addFor && (
<div className="mt-2 rounded-md border border-brand-200 bg-brand-50/50 p-3">
<div className="mb-2 text-[12px] font-semibold text-brand-800">
Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'}
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-[14rem] flex-1">
<span className="label-eyebrow">Hạng mục</span>
<select
value={addCatalogId}
onChange={(e) => setAddCatalogId(e.target.value)}
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700"
>
<option value=""> Chọn hạng mục </option>
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))}
</select>
</label>
<label className="w-[9rem]">
<span className="label-eyebrow">Giá đ xuất</span>
<input
type="number" min={0} step={1000}
value={addAmount}
onChange={(e) => setAddAmount(e.target.value)}
placeholder="0"
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums"
/>
</label>
<label className="min-w-[10rem] flex-1">
<span className="label-eyebrow">Ghi chú</span>
<input
type="text" maxLength={1000}
value={addNote}
onChange={(e) => setAddNote(e.target.value)}
placeholder="(tùy chọn)"
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
/>
</label>
<div className="flex gap-1.5">
<Button
size="sm"
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
onClick={() => addLine.mutate({
supplierId: addFor.supplierId,
catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount),
note: addNote.trim() || undefined,
})}
>
{addLine.isPending ? 'Đang thêm…' : 'Thêm'}
</Button>
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
</div>
</div>
</div>
)}
</>
)}
{/* 🧊 [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ăn cứ hồ 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 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ử </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
const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.'
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
: unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
: undefined
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>
)
}