// Detail content cho 1 phiếu Duyệt NCC. Flat render (no tabs): Thông tin +
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
// → PeApprovalsSection + PeHistorySection).
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { Check, ChevronDown, ChevronRight, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
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 { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
PeAttachmentPurpose,
PeAttachmentPurposeLabel,
PeDepartmentKind,
PeDepartmentKindLabel,
PeDisplayStatusColor,
PeDisplayStatusLabel,
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
getPeDisplayStatus,
isEditablePhase,
type PeAttachment,
type PeChangelog,
type PeDepartmentOpinion,
type PeDetailBundle,
type PeDetailRow,
type PeLevelOpinion,
type PeQuote,
type PeSupplier,
} from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import type { Paged, Supplier } from '@/types/master'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
// Main detail content — flat render 3 section không tabs.
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
//
// `mode` (2026-05-07):
// - 'detail' (default): full UX — Section 5 Ý kiến 4PB editable theo readOnly.
// Dùng ở leaf "Danh sách" + "Duyệt" (3-panel pages).
// - 'workspace': dùng ở leaf "Thao tác" (2-panel workspace). Section 5 LUÔN
// disabled (Q5 user — ý kiến nhập khi duyệt, không phải workspace nhập liệu).
// Workflow Panel + Approvals + History KHÔNG render trong PeDetailTabs (luôn
// ở caller PeWorkflowPanel — workspace caller skip render Panel 3 hoàn toàn).
export function PeDetailTabs({
evaluation,
onBack,
onDelete,
readOnly = false,
mode = 'detail',
autoEditHeader = false,
}: {
evaluation: PeDetailBundle
onBack: () => void
onDelete: () => void
/** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
readOnly?: boolean
/** 'workspace' = Section 5 LUÔN disabled (ý kiến nhập ở leaf Duyệt). */
mode?: 'detail' | 'workspace'
/** Auto open Section 1 InfoTab in edit mode khi mount — triggered từ pencil icon Panel 1 */
autoEditHeader?: boolean
}) {
const qc = useQueryClient()
// canEditPhase: bao gồm cả TraLai (user 2026-05-07). Header bar action
// buttons "Sửa header" + "Xóa" + "Đóng" workspace mode đã chuyển xuống bottom
// action bar (B11+ user 2026-05-07).
const canEditPhase = isEditablePhase(evaluation.phase)
const opinionsReadOnly = readOnly || mode === 'workspace'
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
const submitForApproval = useMutation({
mutationFn: async () => {
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: next,
decision: 1,
comment: null,
})
},
onSuccess: () => {
toast.success('Đã gửi duyệt phiếu — chuyển sang quy trình duyệt.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
onBack()
},
onError: e => toast.error(getErrorMessage(e)),
})
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
const canSubmitForApproval = mode === 'workspace'
&& canEditPhase
&& !readOnly
&& forwardPhase != null
// Tooltip reason cho button disabled (giúp diagnose tại sao "Lưu & Gửi Duyệt"
// không bấm được — user feedback 2026-05-07).
const submitDisabledReason = !canEditPhase
? `Phiếu đã ở phase ${PurchaseEvaluationPhaseLabel[evaluation.phase]} — chỉ Bản nháp / Trả lại mới sửa + gửi được.`
: readOnly
? 'Chế độ chỉ đọc.'
: !forwardPhase
? `Workflow không có phase tiếp theo từ ${PurchaseEvaluationPhaseLabel[evaluation.phase]}. Liên hệ admin kiểm tra cấu hình quy trình.`
: null
return (
{evaluation.tenGoiThau}
{/* Display status meta (Bản nháp / Đã gửi duyệt / Đã duyệt / Từ chối)
— phase chi tiết hiện ở Workflow timeline Panel 3. */}
{PeDisplayStatusLabel[getPeDisplayStatus(evaluation.phase)]}
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
{readOnly && (
chế độ duyệt
)}
{evaluation.maPhieu ?? '—'}
·
{PurchaseEvaluationTypeLabel[evaluation.type]}
·
{evaluation.projectName}
{evaluation.drafterName && <>· Soạn: {evaluation.drafterName} >}
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
giữ Đóng cho non-workspace view (Danh sách + Duyệt — readOnly). */}
{(readOnly || mode !== 'workspace') && (
← Đóng
)}
{/* Section layout (Session 20 Chunk B): Hạng mục nested expand chứa NCC
(tầng 1 = hạng mục, tầng 2 = NCC tham gia + báo giá inline). NCC
tham gia section riêng bỏ — gộp vào Section 2 expand panel. Tên
hạng mục + giá trị auto từ gói thầu (Chunk A BE seed). */}
{mode === 'workspace' && (
Ý kiến + chữ ký auto đồng bộ khi NV duyệt phiếu — vào menu “Duyệt” để ký.
)}
{/* Mig 26 — V2 dynamic theo ApprovalWorkflowLevel. V1 phiếu cũ
fallback render 4 box CỨNG readOnly (data legacy giữ Mig 15). */}
{evaluation.approvalWorkflowId
?
: }
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
- Xóa phiếu (CHỈ Bản nháp, soft-delete BE) — bên trái red
- Lưu (toast confirm, KHÔNG đóng workspace) — chính giữa ghost
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
User 2026-05-07. */}
{mode === 'workspace' && canEditPhase && !readOnly && (
{/* Xóa phiếu — CHỈ DangSoanThao (bản nháp). TraLai không cho xóa
(đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
{
if (confirm(`Xóa phiếu "${evaluation.tenGoiThau}"? Phiếu sẽ ẩn khỏi danh sách (soft-delete, không xóa hoàn toàn trong DB).`)) {
onDelete()
}
}}
className="gap-1.5 text-xs"
>
Xóa phiếu
)}
✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
{
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
toast.success('Đã lưu — sync server.')
}}
className="text-xs"
>
Lưu
{
if (!forwardPhase) return
if (confirm(`Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`)) {
submitForApproval.mutate()
}
}}
disabled={!canSubmitForApproval || submitForApproval.isPending}
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
className="text-xs"
>
{submitForApproval.isPending ? 'Đang gửi…' : 'Lưu & Gửi Duyệt →'}
)}
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
)
}
// ===== Section 5 — Ý kiến 4 phòng ban =====
// Render 2x2 grid 4 box (Phê duyệt / CCM / MuaHàng / SM-PM). Mỗi box hiển
// thị Opinion text + chữ ký (UserName + SignedAt) nếu đã ký, hoặc form nhập
// + 2 button "Lưu" + "Lưu & Ký" khi chưa ký / readOnly=false.
function DepartmentOpinionsSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const KINDS: { kind: number; label: string }[] = [
{ kind: PeDepartmentKind.PheDuyet, label: PeDepartmentKindLabel[PeDepartmentKind.PheDuyet] },
{ kind: PeDepartmentKind.Ccm, label: PeDepartmentKindLabel[PeDepartmentKind.Ccm] },
{ kind: PeDepartmentKind.MuaHang, label: PeDepartmentKindLabel[PeDepartmentKind.MuaHang] },
{ kind: PeDepartmentKind.SmPm, label: PeDepartmentKindLabel[PeDepartmentKind.SmPm] },
]
return (
{KINDS.map(k => {
const existing = ev.departmentOpinions.find(o => o.kind === k.kind) ?? null
return (
)
})}
)
}
function OpinionBox({
evaluationId,
kind,
kindLabel,
existing,
readOnly,
}: {
evaluationId: string
kind: number
kindLabel: string
existing: PeDepartmentOpinion | null
readOnly: boolean
}) {
const qc = useQueryClient()
const [text, setText] = useState(existing?.opinion ?? '')
const isSigned = !!existing?.signedAt
const save = useMutation({
mutationFn: async (sign: boolean) =>
api.post(`/purchase-evaluations/${evaluationId}/opinions`, {
kind,
opinion: text || null,
sign,
}),
onSuccess: () => {
toast.success('Đã lưu ý kiến.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
{kindLabel}
{isSigned && (
Đã ký
)}
{readOnly ? (
<>
{existing?.opinion ?? — chưa có ý kiến }
{isSigned && (
Ký bởi {existing?.userName ?? '—'} · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}
)}
>
) : (
<>
)
}
// ===== Section 5 V2 — Ý kiến cấp duyệt dynamic (Mig 26 — Session 19) =====
//
// Render theo workflow đã pin: forEach Step → forEach Level (Cấp) → forEach
// approver (NV). Mỗi NV = 1 OpinionBox (read-only). Service ApproveV2Async
// auto sync comment khi duyệt (Q1=1B). Empty list → fallback message.
//
// Layout 5A: header "Bước N — Phòng X" badge + grid-cols-2 cho N approvers
// (wrap nếu N>2). Admin override badge khi SignedByUserId !== ApproverUserId.
// Session 20 Chunk C (revised): gộp opinions đồng cấp cùng Phòng → 1 wrapper box / Step,
// BÊN TRONG render từng NV đã duyệt thành các "ô vuông" card mirror visual S19
// (grid-cols-2 cards). User feedback turn 2: giữ visual ô vuông như trước.
//
// Counter fix turn 2: "Số bước duyệt" (= số Cấp / Step) KHÁC "số người duyệt trong
// 1 bước" (= tổng NV across Cấp, OR-of-N nên chỉ 1 NV/Cấp cần ký). Counter đúng
// hiển thị X/Y cấp đã duyệt + thông tin phụ tổng NV tham gia.
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
const flow = ev.approvalFlow
const opinions = ev.levelOpinions
if (!flow || flow.steps.length === 0) {
return (
Workflow chưa được cấu hình hoặc chưa có cấp duyệt nào.
)
}
return (
{flow.steps.map(step => {
const totalLevels = step.levels.length
const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0)
const stepOpinions = opinions
.filter(o => o.stepOrder === step.order)
.slice()
.sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt))
const signedLevels = new Set(stepOpinions.map(o => o.levelOrder)).size
return (
)
})}
)
}
function StepOpinionsBox({
stepOrder, stepName, departmentName, totalLevels, totalApprovers, signedLevels, opinions,
}: {
stepOrder: number
stepName: string
departmentName?: string | null
totalLevels: number // số Cấp (bước duyệt nhỏ trong Step)
totalApprovers: number // tổng NV tham gia (FYI — OR-of-N nên không cần ký hết)
signedLevels: number // số Cấp đã có ít nhất 1 NV ký
opinions: PeLevelOpinion[]
}) {
return (
Bước {stepOrder} — {stepName}
{departmentName && (
{departmentName}
)}
{signedLevels}/{totalLevels} cấp đã duyệt · {totalApprovers} NV tham gia
{opinions.length === 0 ? (
— Chưa có ý kiến duyệt.
) : (
{opinions.map(o => )}
)}
)
}
function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
const isAdminOverride = opinion.signedByUserId !== opinion.approverUserId
return (
Cấp {opinion.levelOrder} — {opinion.approverFullName}
{isAdminOverride && (
⚠ Admin {opinion.signedByFullName} duyệt thay
)}
Đã duyệt
{opinion.comment}
{new Date(opinion.signedAt).toLocaleString('vi-VN')}
)
}
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
return (
Lịch sử duyệt ({ev.approvals.length})
)
}
export function PeHistorySection({ ev }: { ev: PeDetailBundle }) {
return (
Lịch sử thay đổi
)
}
// ===== Section 1 — Thông tin gói thầu (spec: a. Tên gói thầu / b. Dự án) =====
// Inline editable khi canEdit (=!readOnly && phase editable). Edit pencil button
// "Sửa" flip display ↔ form mode. Save dùng existing PUT /pe/:id endpoint với
// current entity values + new header fields. Dự án + Type LOCKED sau create —
// chỉ Tên/Địa điểm/Mô tả/Payment editable inline. autoEdit prop cho phép trigger
// edit mode từ pencil icon trong PeListPanel (URL flag ?editHeader=1).
// Phase editable = DangSoanThao + TraLai (user 2026-05-07).
function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boolean; autoEdit: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
const [editing, setEditing] = useState(autoEdit && canEdit)
const [tenGoiThau, setTenGoiThau] = useState(ev.tenGoiThau)
const [diaDiem, setDiaDiem] = useState(ev.diaDiem ?? '')
const [moTa, setMoTa] = useState(ev.moTa ?? '')
const [paymentTerms, setPaymentTerms] = useState(ev.paymentTerms ?? '')
// User 2026-05-07: re-trigger editing mode khi click pencil ở Panel 1 cho
// PHIẾU KHÁC (ev.id thay đổi) hoặc autoEdit prop change. useState init chỉ
// chạy mount-time → cần useEffect sync khi parent re-render với props mới.
useEffect(() => {
if (autoEdit && canEdit) {
setEditing(true)
// Sync values từ ev mới (tránh stale state khi switch giữa 2 phiếu)
setTenGoiThau(ev.tenGoiThau)
setDiaDiem(ev.diaDiem ?? '')
setMoTa(ev.moTa ?? '')
setPaymentTerms(ev.paymentTerms ?? '')
}
}, [autoEdit, canEdit, ev.id, ev.tenGoiThau, ev.diaDiem, ev.moTa, ev.paymentTerms])
const dirty = tenGoiThau !== ev.tenGoiThau
|| diaDiem !== (ev.diaDiem ?? '')
|| moTa !== (ev.moTa ?? '')
|| paymentTerms !== (ev.paymentTerms ?? '')
const save = useMutation({
mutationFn: async () => {
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau,
diaDiem: diaDiem || null,
moTa: moTa || null,
paymentTerms: paymentTerms || null,
budgetId: ev.budgetId,
budgetManualName: ev.budgetManualName,
budgetManualAmount: ev.budgetManualAmount,
})
},
onSuccess: () => {
toast.success('Đã cập nhật thông tin')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setEditing(false)
},
onError: e => toast.error(getErrorMessage(e)),
})
function reset() {
setTenGoiThau(ev.tenGoiThau)
setDiaDiem(ev.diaDiem ?? '')
setMoTa(ev.moTa ?? '')
setPaymentTerms(ev.paymentTerms ?? '')
}
if (!editing) {
return (
{canEdit && (
setEditing(true)}
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-slate-500 hover:bg-slate-100 hover:text-brand-600"
title="Sửa thông tin gói thầu"
>
Sửa
)}
{(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
{ev.diaDiem &&
Địa điểm: {ev.diaDiem}
}
{ev.moTa &&
Mô tả: {ev.moTa}
}
{ev.paymentTerms &&
Điều khoản TT: {ev.paymentTerms}
}
)}
)
}
// Editing mode
return (
{ reset(); setEditing(false) }}
className="h-7 px-3 text-xs"
>
Hủy
save.mutate()}
disabled={!dirty || !tenGoiThau || save.isPending}
className="h-7 px-3 text-xs"
>
{save.isPending ? 'Đang lưu…' : 'Lưu'}
)
}
// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) =====
// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3
// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)".
// Save dùng POST /pe/:id/select-winner endpoint hiện có.
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => {
toast.success('Đã chọn NCC.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
if (!canEdit) {
return (
✓ {ev.selectedSupplierName}
: — (chưa chọn) }
/>
)
}
return (
a. NCC / TP được chọn
setWinner.mutate(e.target.value)}
disabled={ev.suppliers.length === 0 || setWinner.isPending}
className="text-sm"
>
— Chọn NCC từ danh sách Section 3 —
{ev.suppliers.map(s => (
{s.supplierName}{s.displayName ? ` — ${s.displayName}` : ''}
))}
{/* Loading spinner inline khi save có delay (user 2026-05-07) */}
{setWinner.isPending && (
Đang chọn NCC + sync cột giá Section 4…
)}
{ev.suppliers.length === 0 && (
Thêm NCC ở Section 3 trước rồi mới chọn winner.
)}
)
}
// ===== b. Ngân sách inline editor (Mig 17) =====
// Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần
// đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách /
// Duyệt). Edit chỉ enable khi !readOnly + phase editable (DangSoanThao /
// TraLai). Read-only khi pendingMe=1 hoặc phase đã gửi duyệt / đã duyệt /
// từ chối. Empty values hiển thị empty (per user 2026-05-07).
function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
// Detect mode khi mount/refresh: prefer manual mode nếu đã có data manual + ko link
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
const [manualMode, setManualMode] = useState(initialManual)
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
const [manualName, setManualName] = useState(ev.budgetManualName ?? '')
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
// Eligible budgets — chỉ fetch khi user có khả năng edit
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', ev.projectId],
queryFn: async () => (await api.get>('/budgets', {
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: canEdit,
})
// Dirty detect — compare current state vs ev original
const dirty = manualMode !== initialManual
|| (manualMode && (manualName !== (ev.budgetManualName ?? '') || manualAmount !== (ev.budgetManualAmount ?? 0)))
|| (!manualMode && budgetId !== (ev.budgetId ?? ''))
const save = useMutation({
mutationFn: async () => {
const payload = manualMode
? { budgetId: null, budgetManualName: manualName || null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau: ev.tenGoiThau,
diaDiem: ev.diaDiem,
moTa: ev.moTa,
paymentTerms: ev.paymentTerms,
...payload,
})
},
onSuccess: () => {
toast.success('Đã cập nhật ngân sách')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
// Read-only mode: chỉ display (không toggle, không edit)
if (!canEdit) {
return (
{ev.budget.maNganSach ?? '—'}
{' · '}{ev.budget.tenNganSach}
{' · '}{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
{ev.budgetManualName && {ev.budgetManualName} }
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
{ev.budgetManualAmount != null && (
{ev.budgetManualAmount.toLocaleString('vi-VN')} đ
)}
nhập tay
) : — }
/>
)
}
// Editable mode (canEdit=true)
return (
)
}
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
const [createOpen, setCreateOpen] = useState(false)
// c. Giá chào thầu = sum quotes của NCC được chọn (winner)
const winnerSupplierRowId = ev.selectedSupplierId
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
: null
const giaChaoThau = winnerSupplierRowId
? ev.details
.flatMap(d => d.quotes)
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
.reduce((sum, q) => sum + q.thanhTien, 0)
: null
// d. Bản so sánh — attachments với purpose=ComparisonTable hoặc supplier-row null
const banSoSanhAttachments = ev.attachments.filter(
a => a.purchaseEvaluationSupplierId === null,
)
return (
— (chọn NCC/TP ở (a) trước)
) : giaChaoThau === 0 ? (
— (chưa nhập báo giá ở Section 4)
) : (
{giaChaoThau!.toLocaleString('vi-VN')} đ
)
}
/>
{ev.paymentTerms && (
{ev.paymentTerms}} />
)}
{ev.contractId && (
✓ Xem HĐ}
/>
)}
{canCreateContract && (
✓ Phiếu đã duyệt. Bấm để tạo HĐ mới kế thừa NCC + hạng mục.
setCreateOpen(true)} className="gap-1.5 text-xs">
Tạo HĐ từ phiếu
)}
{createOpen && setCreateOpen(false)} />}
)
}
// Form row: label cố định 176px (w-44) bên trái + value bên phải (giống spec).
function FormRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
{label}
{value}
)
}
function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) {
const navigate = useNavigate()
const [form, setForm] = useState({
contractType: 1,
tenHopDong: evaluation.tenGoiThau,
bypassProcurementAndCCM: false,
})
const mut = useMutation({
mutationFn: async () =>
api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form),
onSuccess: res => {
toast.success('Đã tạo HĐ từ phiếu.')
navigate(`/contracts/${res.data.contractId}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
const typeOptions = [
[1, 'HĐ Thầu phụ'],
[2, 'HĐ Giao khoán'],
[3, 'HĐ Nhà cung cấp'],
[4, 'HĐ Dịch vụ'],
[5, 'HĐ Mua bán'],
[6, 'HĐ Nguyên tắc NCC'],
[7, 'HĐ Nguyên tắc DV'],
] as const
return (
Hủy
mut.mutate()} disabled={mut.isPending}>Tạo
>}
>
)
}
// Session 20 Chunk B: SuppliersTab function bỏ — NCC list giờ render nested
// trong HangMucCard (expand panel mỗi hạng mục). 2 dialog Add/Edit Supplier
// vẫn giữ vì HangMucCard call lại.
function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; onClose: () => void }) {
const qc = useQueryClient()
const suppliers = useQuery({
queryKey: ['all-suppliers'],
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000 } })).data.items,
})
const [form, setForm] = useState({
supplierId: '',
displayName: '',
contactName: '',
contactEmail: '',
contactPhone: '',
paymentTermText: '',
note: '',
})
const mut = useMutation({
mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form),
onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
Hủy
mut.mutate()} disabled={!form.supplierId || mut.isPending}>Thêm
>}
>
NCC (master)
setForm({ ...form, supplierId: e.target.value })}>
-- Chọn --
{suppliers.data?.map(s => (
{s.code} — {s.name}
))}
)
}
function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeSupplier; onClose: () => void }) {
const qc = useQueryClient()
const [form, setForm] = useState({
supplierId: row.supplierId,
displayName: row.displayName ?? '',
contactName: row.contactName ?? '',
contactEmail: row.contactEmail ?? '',
contactPhone: row.contactPhone ?? '',
paymentTermText: row.paymentTermText ?? '',
note: row.note ?? '',
})
const mut = useMutation({
mutationFn: async () => api.put(`/purchase-evaluations/${evaluationId}/suppliers/${row.id}`, form),
onSuccess: () => { toast.success('Đã cập nhật.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
Hủy
mut.mutate()} disabled={mut.isPending}>Lưu
>}
>
)
}
// ===== Tab: Hạng mục + Báo giá (Session 20 — nested cards layout) =====
// Mỗi hạng mục = 1 card với expand panel chứa NCC tham gia inline grid.
// Replace bảng matrix grid (hạng mục × NCC) cũ — user demo 1 hạng mục.
function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState(null)
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
const budgetBundle = useQuery({
queryKey: ['budget-detail-for-pe', ev.budgetId],
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
`/budgets/${ev.budgetId}`)).data,
enabled: !!ev.budgetId,
})
const budgetRowMap = (() => {
const m = new Map()
budgetBundle.data?.details.forEach(d => {
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
})
return m
})()
const showBudgetCol = !!ev.budgetId
return (
{ev.details.length} hạng mục · {ev.suppliers.length} NCC tham gia
{!readOnly && ' — mở hạng mục để thêm NCC + nhập báo giá.'}
{!readOnly && (
setAddOpen(true)} className="gap-1.5 text-xs">
Thêm hạng mục
)}
{ev.details.length === 0 ? (
Chưa có hạng mục.
) : (
{ev.details.map(d => (
setEditDetail(d)}
/>
))}
)}
{addOpen &&
setAddOpen(false)} />}
{editDetail && setEditDetail(null)} />}
)
}
// Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand.
// Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click.
function HangMucCard({
detail, ev, readOnly, budgetRowMap, showBudgetCol, onEditDetail,
}: {
detail: PeDetailRow
ev: PeDetailBundle
readOnly: boolean
budgetRowMap: Map
showBudgetCol: boolean
onEditDetail: () => void
}) {
const qc = useQueryClient()
const [expanded, setExpanded] = useState(true)
const [addNccOpen, setAddNccOpen] = useState(false)
const [editNccRow, setEditNccRow] = useState(null)
const [quoteEdit, setQuoteEdit] = useState<{ supplier: PeSupplier; existing: PeQuote | null } | null>(null)
const removeDetail = useMutation({
mutationFn: async () => api.delete(`/purchase-evaluations/${ev.id}/details/${detail.id}`),
onSuccess: () => { toast.success('Đã xóa hạng mục.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const removeNcc = useMutation({
mutationFn: async (rowId: string) => api.delete(`/purchase-evaluations/${ev.id}/suppliers/${rowId}`),
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => { toast.success('Đã chọn NCC thắng.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const bgValue = budgetRowMap.get(`${detail.groupCode}|${detail.itemCode ?? ''}`)
const delta = bgValue != null ? detail.thanhTienNganSach - bgValue : null
return (
{/* Header row — hạng mục info + actions */}
setExpanded(!expanded)}
className="mt-0.5 text-slate-400 hover:text-slate-700"
title={expanded ? 'Đóng' : 'Mở'}
>
{expanded ? : }
{detail.groupCode}
{detail.noiDung}
{detail.groupName}{detail.donViTinh ? ` · ĐVT: ${detail.donViTinh}` : ''}
KL
{detail.khoiLuongNganSach}
ĐG ngân sách
{fmtMoney(detail.donGiaNganSach)}
Thành tiền NS
{fmtMoney(detail.thanhTienNganSach)}
{showBudgetCol && bgValue != null && (
NS link
{fmtMoney(bgValue)}
0 && 'text-red-600',
delta! < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
Δ {delta! > 0 ? '+' : ''}{fmtMoney(delta!)}
)}
{!readOnly && (
{ if (confirm('Xóa hạng mục? Báo giá NCC đã nhập cũng sẽ mất.')) removeDetail.mutate() }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa hạng mục"
>
)}
{/* Expand panel — NCC tham gia + báo giá inline */}
{expanded && (
NCC tham gia ({ev.suppliers.length})
{!readOnly && (
setAddNccOpen(true)} className="gap-1.5 text-xs">
Thêm NCC
)}
{ev.suppliers.length === 0 ? (
{readOnly ? 'Chưa có NCC tham gia.' : 'Chưa có NCC. Thêm NCC để nhập báo giá.'}
) : (
NCC
Liên hệ
Điều khoản TT
File báo giá
ĐG chưa VAT
ĐG có VAT
Thành tiền
{!readOnly && }
{ev.suppliers.map(s => {
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
const isWinner = ev.selectedSupplierId === s.supplierId
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes
const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
const cellHover = !readOnly && 'cursor-pointer hover:bg-brand-50'
return (
{isWinner && ✓ }{s.supplierName}
{s.displayName && {s.displayName}
}
{s.note && {s.note}
}
{s.contactName && {s.contactName}
}
{s.contactPhone && {s.contactPhone}
}
{s.contactEmail && {s.contactEmail}
}
{!s.contactName && !s.contactPhone && !s.contactEmail && — }
{s.paymentTermText ?? — }
a.purchaseEvaluationSupplierId === s.id)}
readOnly={readOnly}
/>
{q ? fmtMoney(q.chuaVat) : — }
{q ? fmtMoney(q.bgVat) : — }
{q ? fmtMoney(q.thanhTien) : — }
{!readOnly && (
setWinner.mutate(s.supplierId)}
className={cn(
'rounded px-1 py-0.5',
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)}
title={isWinner ? 'NCC đã được chọn (winner)' : 'Chọn NCC thắng'}
>
{!isWinner && (
setEditNccRow(s)}
className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa thông tin NCC"
>
)}
{canDelete ? (
{ if (confirm('Xóa NCC này khỏi phiếu?')) removeNcc.mutate(s.id) }}
className="rounded px-1 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa NCC"
>
) : !isWinner && hasQuotes && (
)}
)}
)
})}
)}
)}
{addNccOpen &&
setAddNccOpen(false)} />}
{editNccRow && setEditNccRow(null)} />}
{quoteEdit && (
setQuoteEdit(null)}
/>
)}
)
}
function DetailDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeDetailRow | null; onClose: () => void }) {
const qc = useQueryClient()
const [form, setForm] = useState({
groupCode: row?.groupCode ?? 'A.I',
groupName: row?.groupName ?? '',
itemCode: row?.itemCode ?? '',
noiDung: row?.noiDung ?? '',
donViTinh: row?.donViTinh ?? '',
khoiLuongNganSach: row?.khoiLuongNganSach ?? 0,
khoiLuongThiCong: row?.khoiLuongThiCong ?? 0,
donGiaNganSach: row?.donGiaNganSach ?? 0,
thanhTienNganSach: row?.thanhTienNganSach ?? 0,
ghiChu: row?.ghiChu ?? '',
})
const mut = useMutation({
mutationFn: async () =>
row
? api.put(`/purchase-evaluations/${evaluationId}/details/${row.id}`, form)
: api.post(`/purchase-evaluations/${evaluationId}/details`, form),
onSuccess: () => { toast.success(row ? 'Đã sửa.' : 'Đã thêm.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const updateAndRecalc = (patch: Partial) => {
const next = { ...form, ...patch }
// Auto-compute ThanhTien = KL ngân sách × ĐG ngân sách
next.thanhTienNganSach = Number(next.khoiLuongNganSach) * Number(next.donGiaNganSach)
setForm(next)
}
return (
Hủy
mut.mutate()} disabled={mut.isPending}>{row ? 'Lưu' : 'Thêm'}
>}
>
)
}
function QuoteDialog({
evaluationId, detailId, supplierRowId, supplierName, itemName, khoiLuong, existing, onClose,
}: {
evaluationId: string
detailId: string
supplierRowId: string
supplierName: string
itemName: string
khoiLuong: number
existing: PeQuote | null
onClose: () => void
}) {
const qc = useQueryClient()
// User 2026-05-07: Bỏ `isSelected` checkbox per-quote (consolidate winner
// selection ở Section 2.a NccSelectorRow). BE vẫn nhận isSelected nhưng FE
// luôn gửi `false` (existing.isSelected nếu có để giữ nguyên trạng thái cũ).
const [form, setForm] = useState({
bgVat: existing?.bgVat ?? 0,
chuaVat: existing?.chuaVat ?? 0,
thanhTien: existing?.thanhTien ?? 0,
note: existing?.note ?? '',
})
const updateAndRecalc = (patch: Partial) => {
const next = { ...form, ...patch }
next.thanhTien = Number(next.chuaVat) * khoiLuong
setForm(next)
}
const mut = useMutation({
mutationFn: async () =>
api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
purchaseEvaluationSupplierId: supplierRowId,
...form,
isSelected: existing?.isSelected ?? false, // giữ nguyên trạng thái cũ, không expose UI
}),
onSuccess: () => { toast.success('Đã lưu báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async () =>
existing ? api.delete(`/purchase-evaluations/${evaluationId}/quotes/${existing.id}`) : Promise.resolve(),
onSuccess: () => { toast.success('Đã xóa báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const isSaving = mut.isPending || del.isPending
return (
{existing && del.mutate()} disabled={isSaving}>{del.isPending ? 'Đang xóa…' : 'Xóa'} }
Hủy
mut.mutate()} disabled={isSaving}>{mut.isPending ? 'Đang lưu…' : 'Lưu'}
>}
>
{/* Loading overlay khi save có delay (user 2026-05-07) */}
{isSaving && (
{mut.isPending ? 'Đang lưu báo giá…' : 'Đang xóa…'}
)}
Hạng mục: {itemName} · KL {khoiLuong}
Ghi chú setForm({ ...form, note: e.target.value })} />
)
}
// ===== Tab: Duyệt =====
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
if (ev.approvals.length === 0) return Chưa có bước duyệt nào.
return (
{ev.approvals.map(a => (
{PurchaseEvaluationPhaseLabel[a.fromPhase]}
→
{PurchaseEvaluationPhaseLabel[a.toPhase]}
{new Date(a.approvedAt).toLocaleString('vi-VN')}
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
))}
)
}
// ===== Tab: Lịch sử =====
function HistoryTab({ ev }: { ev: PeDetailBundle }) {
const logs = useQuery({
queryKey: ['pe-changelog', ev.id],
queryFn: async () => (await api.get(`/purchase-evaluations/${ev.id}/changelogs`)).data,
})
if (logs.isLoading) return Đang tải…
// User UAT 2026-05-08: chỉ track events liên quan Trả lại + Gửi duyệt lại.
// Bỏ trạng thái duyệt (Cấp 1 → Cấp 2 → DaDuyet) + bỏ thay đổi trước Trả lại.
// Filter giữ:
// - Workflow transition về TraLai (phaseAtChange = TraLai = 98)
// - Workflow transition từ TraLai → khác (Drafter gửi lại — summary chứa "TraLai →")
// - Mọi thay đổi nội dung khi phaseAtChange = TraLai (sửa trong giai đoạn chờ gửi lại)
// BE giữ data đầy đủ (audit trail) — chỉ filter ở UI, reversible.
const PE_PHASE_TRALAI = 98
const PE_ENTITY_WORKFLOW = 5
const filtered = (logs.data ?? []).filter(l => {
if (l.entityType === PE_ENTITY_WORKFLOW) {
if (l.phaseAtChange === PE_PHASE_TRALAI) return true
if (l.summary?.includes('TraLai →')) return true
return false
}
return l.phaseAtChange === PE_PHASE_TRALAI
})
if (filtered.length === 0) return Chưa có lịch sử trả lại / gửi duyệt lại.
return (
{filtered.map(l => (
{l.userName ?? 'Hệ thống'}
{new Date(l.createdAt).toLocaleString('vi-VN')}
{l.summary}
{l.contextNote && {l.contextNote}
}
))}
)
}
// ===== Cell upload file đính kèm per-NCC =====
// 1 row = 1 NCC. User upload file báo giá (purpose=QuoteDocument mặc định) →
// POST multipart với supplierRowId. List N file hiện có + Download/Delete inline.
// Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
function SupplierAttachmentsCell({
evaluationId,
supplierRowId,
attachments,
readOnly = false,
}: {
evaluationId: string
supplierRowId: string
attachments: PeAttachment[]
readOnly?: boolean
}) {
const qc = useQueryClient()
const fileInputRef = useRef(null)
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
fd.append('supplierRowId', supplierRowId)
fd.append('purpose', String(PeAttachmentPurpose.QuoteDocument))
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => {
toast.success('Đã tải lên.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async (attId: string) =>
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
onSuccess: () => {
toast.success('Đã xóa.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
async function download(att: PeAttachment) {
try {
const res = await api.get(
`/purchase-evaluations/${evaluationId}/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))
}
}
function onPick(e: React.ChangeEvent) {
const f = e.target.files?.[0]
if (f) upload.mutate(f)
e.target.value = ''
}
const fmtSize = (b: number) =>
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
return (
{attachments.length === 0 && (
Chưa có file
)}
{attachments.map(a => (
download(a)}
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
title={a.fileName}
>
{a.fileName}
{fmtSize(a.fileSize)}
{PeAttachmentPurposeLabel[a.purpose] ?? ''}
{!readOnly && (
{ if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
)}
))}
{!readOnly && (
fileInputRef.current?.click()}
disabled={upload.isPending}
className="inline-flex items-center gap-1 rounded border border-dashed border-slate-300 px-2 py-0.5 text-[11px] text-slate-500 hover:border-brand-300 hover:text-brand-700 disabled:opacity-50"
>
{upload.isPending ? 'Đang tải…' : '+ Thêm file'}
)}
)
}
// ===== Section Bảng so sánh — general attachments (không gắn NCC cụ thể) =====
// Purpose mặc định = ComparisonTable (4). Upload file Excel/PDF tổng hợp so
// sánh giá N NCC × M hạng mục. Storage path giống SupplierAttachmentsCell
// nhưng supplierRowId KHÔNG truyền → BE lưu NULL.
function GeneralAttachmentsSection({
evaluationId,
attachments,
readOnly = false,
}: {
evaluationId: string
attachments: PeAttachment[]
readOnly?: boolean
}) {
const qc = useQueryClient()
const fileInputRef = useRef(null)
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
// KHÔNG append supplierRowId → BE set NULL → general attachment
fd.append('purpose', String(PeAttachmentPurpose.ComparisonTable))
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => {
toast.success('Đã tải lên bảng so sánh.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async (attId: string) =>
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
onSuccess: () => {
toast.success('Đã xóa.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
async function download(att: PeAttachment) {
try {
const res = await api.get(
`/purchase-evaluations/${evaluationId}/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))
}
}
function onPick(e: React.ChangeEvent) {
const f = e.target.files?.[0]
if (f) upload.mutate(f)
e.target.value = ''
}
const fmtSize = (b: number) =>
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
return (
{!readOnly && (
File Excel/PDF tổng hợp so sánh giá của tất cả NCC (không gắn với 1 NCC cụ thể).
)}
{attachments.length === 0 && readOnly && (
Chưa có bảng so sánh.
)}
{attachments.length > 0 && (
{attachments.map(a => (
download(a)}
className="min-w-0 flex-1 truncate text-left font-medium text-slate-800 hover:text-brand-700 hover:underline"
title={a.fileName}
>
{a.fileName}
{fmtSize(a.fileSize)}
{PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'}
{new Date(a.createdAt).toLocaleDateString('vi-VN')}
{!readOnly && (
{ if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded p-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
)}
))}
)}
{!readOnly && (
fileInputRef.current?.click()}
disabled={upload.isPending}
className="inline-flex 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 disabled:opacity-50"
>
{upload.isPending ? 'Đang tải…' : '+ Tải lên bảng so sánh'}
)}
)
}