// 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 { useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' import { Check, 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, PurchaseEvaluationPhase, PurchaseEvaluationPhaseColor, PurchaseEvaluationPhaseLabel, PurchaseEvaluationTypeLabel, type PeAttachment, type PeChangelog, type PeDetailBundle, type PeDetailRow, type PeQuote, type PeSupplier, } from '@/types/purchaseEvaluation' import type { 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). export function PeDetailTabs({ evaluation, onBack, onDelete, readOnly = 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 }) { const navigate = useNavigate() const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao return (

{evaluation.tenGoiThau}

{PurchaseEvaluationPhaseLabel[evaluation.phase]} {readOnly && ( chế độ duyệt )}
{evaluation.maPhieu ?? '—'} · {PurchaseEvaluationTypeLabel[evaluation.type]} · {evaluation.projectName} {evaluation.drafterName && <>·Soạn: {evaluation.drafterName}}
{isDraft && !readOnly && ( <> )}
) } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
) } // ===== 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

) } // ===== Tab: Thông tin ===== function InfoTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId const [createOpen, setCreateOpen] = useState(false) return (
{ev.contractId && ( ✓ Xem HĐ} /> )}
{canCreateContract && (
✓ Phiếu đã duyệt. Bấm để tạo HĐ mới kế thừa NCC + hạng mục.
)} {createOpen && setCreateOpen(false)} />}
) } 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 ( } >

NCC: {evaluation.selectedSupplierName} · Dự án: {evaluation.projectName}

setForm({ ...form, tenHopDong: e.target.value })} />
) } function Field({ label, value }: { label: string; value: React.ReactNode }) { return (
{label}
{value}
) } // ===== Tab: NCC ===== function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { const qc = useQueryClient() const [open, setOpen] = useState(false) const [editRow, setEditRow] = useState(null) const remove = 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)), }) return (
{!readOnly && (
)} {ev.suppliers.length === 0 ? (

{readOnly ? 'Chưa có NCC.' : 'Chưa có NCC. Thêm NCC để bắt đầu so sánh giá.'}

) : (
{!readOnly && } {ev.suppliers.map(s => ( {!readOnly && ( )} ))}
NCC Liên hệ Điều khoản TT File đính kèm
{s.supplierName}
{s.displayName &&
{s.displayName}
} {s.note &&
{s.note}
} {readOnly && ev.selectedSupplierId === s.supplierId && (
✓ NCC được chọn
)}
{s.contactName &&
{s.contactName}
} {s.contactPhone &&
{s.contactPhone}
} {s.contactEmail &&
{s.contactEmail}
}
{s.paymentTermText ?? '—'} a.purchaseEvaluationSupplierId === s.id)} readOnly={readOnly} />
)} {open && setOpen(false)} />} {editRow && setEditRow(null)} />}
) } 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 ( } >
setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" />
setForm({ ...form, paymentTermText: e.target.value })} placeholder="vd 30 ngày, 300tr" />
setForm({ ...form, contactName: e.target.value })} />
setForm({ ...form, contactPhone: e.target.value })} />
setForm({ ...form, contactEmail: e.target.value })} />
setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." />
) } 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 ( } >
setForm({ ...form, displayName: e.target.value })} />
setForm({ ...form, paymentTermText: e.target.value })} />
setForm({ ...form, contactName: e.target.value })} />
setForm({ ...form, contactPhone: e.target.value })} />
setForm({ ...form, contactEmail: e.target.value })} />
setForm({ ...form, note: e.target.value })} />
) } // ===== Tab: Hạng mục + Báo giá (matrix) ===== function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { const qc = useQueryClient() const [addOpen, setAddOpen] = useState(false) const [editDetail, setEditDetail] = useState(null) const [quoteEdit, setQuoteEdit] = useState<{ detail: PeDetailRow; supplier: PeSupplier; existing: PeQuote | null } | null>(null) const removeDetail = useMutation({ mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${ev.id}/details/${id}`), onSuccess: () => { toast.success('Đã xóa hạng mục.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onError: e => toast.error(getErrorMessage(e)), }) const quoteKey = (detailId: string, supplierRowId: string) => ev.details.find(d => d.id === detailId)?.quotes.find(q => q.purchaseEvaluationSupplierId === supplierRowId) ?? null return (

{ev.suppliers.length === 0 ? (readOnly ? 'Chưa có NCC tham gia.' : 'Thêm NCC ở tab "NCC" trước khi nhập báo giá.') : readOnly ? `${ev.details.length} hạng mục × ${ev.suppliers.length} NCC` : `${ev.details.length} hạng mục × ${ev.suppliers.length} NCC — click ô để nhập báo giá.`}

{!readOnly && ( )}
{ev.details.length === 0 ? (

Chưa có hạng mục.

) : (
{ev.suppliers.map(s => ( ))} {!readOnly && } {ev.details.map(d => ( {ev.suppliers.map(s => { const q = quoteKey(d.id, s.id) return ( ) })} {!readOnly && ( )} ))}
Hạng mục KL ĐG ngân sách TT ngân sách {s.displayName ?? s.supplierName}
{d.groupCode} {d.noiDung}
{d.groupName} · {d.donViTinh ?? ''}
{d.khoiLuongNganSach} {fmtMoney(d.donGiaNganSach)} {fmtMoney(d.thanhTienNganSach)} setQuoteEdit({ detail: d, supplier: s, existing: q })} className={cn( 'border-r border-slate-200 px-2 py-2 text-right font-mono transition', !readOnly && 'cursor-pointer hover:bg-brand-50', q?.isSelected && 'bg-emerald-50 font-semibold text-emerald-700', )} > {q ? fmtMoney(q.thanhTien) : }
)} {addOpen && setAddOpen(false)} />} {editDetail && setEditDetail(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 ( } >
setForm({ ...form, groupCode: e.target.value })} />
setForm({ ...form, groupName: e.target.value })} placeholder="Bê tông / Phụ gia..." />
setForm({ ...form, itemCode: e.target.value })} />
setForm({ ...form, noiDung: e.target.value })} />
setForm({ ...form, donViTinh: e.target.value })} />
updateAndRecalc({ khoiLuongNganSach: Number(e.target.value) })} />
setForm({ ...form, khoiLuongThiCong: Number(e.target.value) })} />
updateAndRecalc({ donGiaNganSach: Number(e.target.value) })} />
setForm({ ...form, thanhTienNganSach: Number(e.target.value) })} />
setForm({ ...form, ghiChu: e.target.value })} />
) } 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() const [form, setForm] = useState({ bgVat: existing?.bgVat ?? 0, chuaVat: existing?.chuaVat ?? 0, thanhTien: existing?.thanhTien ?? 0, isSelected: existing?.isSelected ?? false, 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, }), 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)), }) return ( {existing && } } >

Hạng mục: {itemName} · KL {khoiLuong}

updateAndRecalc({ chuaVat: Number(e.target.value) })} />
setForm({ ...form, bgVat: Number(e.target.value) })} />
setForm({ ...form, thanhTien: Number(e.target.value) })} />
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 => (
  1. {PurchaseEvaluationPhaseLabel[a.fromPhase]} {PurchaseEvaluationPhaseLabel[a.toPhase]}
    {new Date(a.approvedAt).toLocaleString('vi-VN')}
    {a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
  2. ))}
) } // ===== 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…

if (!logs.data || logs.data.length === 0) return

Chưa có lịch sử.

return (
    {logs.data.map(l => (
  1. {l.userName ?? 'Hệ thống'} {new Date(l.createdAt).toLocaleString('vi-VN')}
    {l.summary}
    {l.contextNote &&
    {l.contextNote}
    }
  2. ))}
) } // ===== 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 => (
{fmtSize(a.fileSize)} {PeAttachmentPurposeLabel[a.purpose] ?? ''} {!readOnly && ( )}
))} {!readOnly && (
)}
) }