// Detail tabs cho 1 phiếu Duyệt NCC: Thông tin / NCC / Hạng mục + Báo giá / // Duyệt / Lịch sử. Inline action dialog để add NCC, add Detail, upsert Quote, // select winner. import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' import { Check, Pencil, Plus, Trash2 } 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 { PurchaseEvaluationPhase, PurchaseEvaluationPhaseColor, PurchaseEvaluationPhaseLabel, PurchaseEvaluationTypeLabel, type PeChangelog, type PeDetailBundle, type PeDetailRow, type PeQuote, type PeSupplier, } from '@/types/purchaseEvaluation' import type { Supplier } from '@/types/master' type TabKey = 'info' | 'suppliers' | 'items' | 'approvals' | 'history' const fmtMoney = (v: number) => v.toLocaleString('vi-VN') export function PeDetailTabs({ evaluation, onBack, onDelete, }: { evaluation: PeDetailBundle onBack: () => void onDelete: () => void }) { const [tab, setTab] = useState('info') const navigate = useNavigate() const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao return (

{evaluation.tenGoiThau}

{PurchaseEvaluationPhaseLabel[evaluation.phase]}
{evaluation.maPhieu ?? '—'} · {PurchaseEvaluationTypeLabel[evaluation.type]} · {evaluation.projectName} {evaluation.drafterName && <>·Soạn: {evaluation.drafterName}}
{isDraft && ( <> )}
{tab === 'info' && } {tab === 'suppliers' && } {tab === 'items' && } {tab === 'approvals' && } {tab === 'history' && }
) } // ===== Tab: Thông tin ===== function InfoTab({ ev }: { ev: PeDetailBundle }) { return (
{ev.contractId && ( ✓ Xem HĐ} /> )}
) } function Field({ label, value }: { label: string; value: React.ReactNode }) { return (
{label}
{value}
) } // ===== Tab: NCC ===== function SuppliersTab({ ev }: { ev: PeDetailBundle }) { 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 (
{ev.suppliers.length === 0 ? (

Chưa có NCC. Thêm NCC để bắt đầu so sánh giá.

) : (
{ev.suppliers.map(s => ( ))}
NCC Hiển thị Liên hệ Điều khoản TT Ghi chú
{s.supplierName} {s.displayName ?? '—'} {s.contactName &&
{s.contactName}
} {s.contactPhone &&
{s.contactPhone}
} {s.contactEmail &&
{s.contactEmail}
}
{s.paymentTermText ?? '—'} {s.note ?? '—'}
)} {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 }: { ev: PeDetailBundle }) { 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 ? 'Thêm NCC ở tab "NCC" trước khi nhập báo giá.' : `${ev.details.length} hạng mục × ${ev.suppliers.length} NCC — click ô để nhập báo giá.`}

{ev.details.length === 0 ? (

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

) : (
{ev.suppliers.map(s => ( ))} {ev.details.map(d => ( {ev.suppliers.map(s => { const q = quoteKey(d.id, s.id) return ( ) })} ))}
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( 'cursor-pointer border-r border-slate-200 px-2 py-2 text-right font-mono transition 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. ))}
) }