[CLAUDE] FE-PE: Chunk B — NCC nested expand dưới Hạng mục, bỏ Section 4 riêng
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

Restructure ItemsTab → nested cards (tầng 1 = hạng mục, tầng 2 = NCC tham
gia + báo giá inline). Replace bảng matrix grid (hạng mục × NCC) cũ — user
Session 20 yêu cầu UX nested cho 1 hạng mục demo.

FE (mirror fe-admin + fe-user):
  - ItemsTab giờ render list HangMucCard (1 card / 1 hạng mục)
  - HangMucCard mới: header info hạng mục + expand panel default OPEN
  - Expand panel: NCC inline table columns:
      NCC | Liên hệ | Điều khoản TT | File báo giá | ĐG chưa VAT | ĐG có VAT | Thành tiền | Action
  - Quote click cell → QuoteDialog cũ (reuse)
  - NCC button: + Thêm NCC (AddSupplierDialog) / ✏ Sửa (EditSupplierDialog) / ✓ Winner / 🗑 Xóa
  - Budget Δ compute per-card (KHÔNG tfoot tổng — 1 hạng mục)
  - Bỏ Section 4 "NCC tham gia" cũ trong main render — gộp vào Section 2
  - Drop function SuppliersTab (dead code) — giữ AddSupplierDialog +
    EditSupplierDialog + SupplierAttachmentsCell cho HangMucCard reuse

Section layout mới (4 section):
  1. Thông tin gói thầu
  2. Hạng mục + Báo giá NCC (nested)
  3. Chọn NCC / TP thắng thầu
  4. Ý kiến cấp duyệt

Verify:
  - npm run build × fe-admin pass (warning chunk size, không liên quan)
  - npm run build × fe-user pass
  - Test pass mặc định skip (Phase 9 UAT iteration, Q4 user public luôn)

Pending Chunk C: Section 5 (rename 4) gộp đồng cấp cùng Phòng — 1 box / Step
Pending Chunk D: Docs S20 changelog + STATUS + HANDOFF

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-11 10:04:49 +07:00
parent 9dee00da01
commit 2bba851135
2 changed files with 570 additions and 590 deletions

View File

@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Check, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react' import { Check, ChevronDown, ChevronRight, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
@ -161,23 +161,20 @@ export function PeDetailTabs({
</div> </div>
<div className="divide-y divide-slate-200"> <div className="divide-y divide-slate-200">
{/* Section order (Session 20): Hạng mục lên đầu sau Thông tin gói thầu. {/* Section layout (Session 20 Chunk B): Hạng mục nested expand chứa NCC
BE auto-tạo 1 hạng mục mặc định (tên = TenGoiThau, giá trị = ngân sách) (tầng 1 = hạng mục, tầng 2 = NCC tham gia + báo giá inline). NCC
khi Create. NCC tham gia tạm giữ riêng — Chunk B sẽ gộp NCC nested tham gia section riêng bỏ — gộp vào Section 2 expand panel. Tên
expand dưới mỗi hạng mục. */} hạng mục + giá trị auto từ gói thầu (Chunk A BE seed). */}
<Section title="1. Thông tin gói thầu"> <Section title="1. Thông tin gói thầu">
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} /> <InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
</Section> </Section>
<Section title={`2. Hạng mục + Báo giá (${evaluation.details.length})`}> <Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
<ItemsTab ev={evaluation} readOnly={readOnly} /> <ItemsTab ev={evaluation} readOnly={readOnly} />
</Section> </Section>
<Section title="3. Chọn NCC / TP"> <Section title="3. Chọn NCC / TP thắng thầu">
<ChonNccSection ev={evaluation} readOnly={readOnly} /> <ChonNccSection ev={evaluation} readOnly={readOnly} />
</Section> </Section>
<Section title={`4. NCC / TP tham gia (${evaluation.suppliers.length})`}> <Section title="4. Ý kiến cấp duyệt (sign-off theo workflow)">
<SuppliersTab ev={evaluation} readOnly={readOnly} />
</Section>
<Section title="5. Ý kiến cấp duyệt (sign-off theo workflow)">
{mode === 'workspace' && ( {mode === 'workspace' && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800"> <div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
Ý kiến + chữ auto đng bộ khi NV duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ . Ý kiến + chữ auto đng bộ khi NV duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
@ -1037,139 +1034,9 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
) )
} }
// ===== Tab: NCC ===== // Session 20 Chunk B: SuppliersTab function bỏ — NCC list giờ render nested
function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { // trong HangMucCard (expand panel mỗi hạng mục). 2 dialog Add/Edit Supplier
const qc = useQueryClient() // vẫn giữ vì HangMucCard call lại.
const [open, setOpen] = useState(false)
const [editRow, setEditRow] = useState<PeSupplier | null>(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 (
<div>
{!readOnly && (
<div className="mb-3 flex justify-end">
<Button onClick={() => setOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Thêm NCC
</Button>
</div>
)}
{ev.suppliers.length === 0 ? (
<p className="text-sm text-slate-500">
{readOnly ? 'Chưa có NCC.' : 'Chưa có NCC. Thêm NCC để bắt đầu so sánh giá.'}
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 text-left">NCC</th>
<th className="px-3 py-2 text-left">Liên hệ</th>
<th className="px-3 py-2 text-left">Điều khoản TT</th>
<th className="px-3 py-2 text-left">File đính kèm</th>
{!readOnly && <th className="px-3 py-2"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.suppliers.map(s => (
<tr key={s.id} className={cn('align-top', ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
<td className="px-3 py-2">
<div className="font-medium text-slate-900">{s.supplierName}</div>
{s.displayName && <div className="text-[11px] text-slate-500">{s.displayName}</div>}
{s.note && <div className="mt-0.5 text-[11px] text-amber-600">{s.note}</div>}
{readOnly && ev.selectedSupplierId === s.supplierId && (
<div className="mt-0.5 text-[11px] font-medium text-emerald-700"> NCC đưc chọn</div>
)}
</td>
<td className="px-3 py-2 text-[12px] text-slate-600">
{s.contactName && <div>{s.contactName}</div>}
{s.contactPhone && <div>{s.contactPhone}</div>}
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
</td>
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</td>
<td className="px-3 py-2">
<SupplierAttachmentsCell
evaluationId={ev.id}
supplierRowId={s.id}
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
readOnly={readOnly}
/>
</td>
{!readOnly && (() => {
// User 2026-05-07: NCC đã được chọn (winner) → KHÔNG cho
// sửa/xóa (tránh thay đổi NCC đã chốt). Chỉ hiển thị
// checkmark active state.
// User 2026-05-07 (B11+): NCC đã có hạng mục báo giá (quotes
// entered in Section 4) → KHÔNG cho xóa (tránh mất báo giá đã nhập).
const isWinner = ev.selectedSupplierId === s.supplierId
const hasQuotes = ev.details.some(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes
return (
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => setWinner.mutate(s.supplierId)}
className={cn(
'rounded px-1.5 py-0.5 text-[11px]',
isWinner
? 'bg-emerald-100 text-emerald-700'
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
)}
title={isWinner ? 'NCC đã được chọn (winner)' : 'Chọn NCC thắng'}
>
<Check className="h-3.5 w-3.5" />
</button>
{!isWinner && (
<button
onClick={() => setEditRow(s)}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{canDelete ? (
<button
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : !isWinner && hasQuotes && (
<span
className="rounded px-1.5 py-0.5 text-slate-300 cursor-not-allowed"
title="NCC đã có báo giá ở Section 4 — xóa báo giá trước rồi mới xóa NCC"
>
<Trash2 className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
)
})()}
</tr>
))}
</tbody>
</table>
</div>
)}
{open && <AddSupplierDialog evaluationId={ev.id} onClose={() => setOpen(false)} />}
{editRow && <EditSupplierDialog evaluationId={ev.id} row={editRow} onClose={() => setEditRow(null)} />}
</div>
)
}
function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; onClose: () => void }) { function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; onClose: () => void }) {
const qc = useQueryClient() const qc = useQueryClient()
@ -1263,24 +1130,14 @@ function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: stri
) )
} }
// ===== Tab: Hạng mục + Báo giá (matrix) ===== // ===== 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 }) { function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient()
const [addOpen, setAddOpen] = useState(false) const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null) const [editDetail, setEditDetail] = useState<PeDetailRow | null>(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
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row. // Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
// Match key: groupCode|itemCode (case-sensitive match; itemCode null cho phép).
const budgetBundle = useQuery({ const budgetBundle = useQuery({
queryKey: ['budget-detail-for-pe', ev.budgetId], queryKey: ['budget-detail-for-pe', ev.budgetId],
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>( queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
@ -1295,18 +1152,13 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
return m return m
})() })()
const showBudgetCol = !!ev.budgetId const showBudgetCol = !!ev.budgetId
const totalPeNganSach = ev.details.reduce((sum, d) => sum + d.thanhTienNganSach, 0)
const totalBudget = budgetBundle.data?.tongNganSach ?? 0
return ( return (
<div> <div>
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
{ev.suppliers.length === 0 {ev.details.length} hạng mục · {ev.suppliers.length} NCC tham gia
? (readOnly ? 'Chưa có NCC tham gia.' : 'Thêm NCC ở tab "NCC" trước khi nhập báo giá.') {!readOnly && ' — mở hạng mục để thêm NCC + 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á.`}
</p> </p>
{!readOnly && ( {!readOnly && (
<Button onClick={() => setAddOpen(true)} className="gap-1.5 text-xs"> <Button onClick={() => setAddOpen(true)} className="gap-1.5 text-xs">
@ -1318,147 +1170,285 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
{ev.details.length === 0 ? ( {ev.details.length === 0 ? (
<p className="text-sm text-slate-500">Chưa hạng mục.</p> <p className="text-sm text-slate-500">Chưa hạng mục.</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="space-y-3">
<table className="min-w-full border border-slate-200 text-xs"> {ev.details.map(d => (
<thead className="bg-slate-50 text-slate-600"> <HangMucCard
<tr> key={d.id}
<th className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-left">Hạng mục</th> detail={d}
<th className="border-r border-slate-200 px-2 py-2 text-right">KL</th> ev={ev}
<th className="border-r border-slate-200 px-2 py-2 text-right">ĐG ngân sách</th> readOnly={readOnly}
<th className="border-r border-slate-200 px-2 py-2 text-right">TT ngân sách</th> budgetRowMap={budgetRowMap}
{showBudgetCol && ( showBudgetCol={showBudgetCol}
<th className="border-r border-slate-200 bg-amber-50 px-2 py-2 text-right" title="So với ngân sách đã link"> onEditDetail={() => setEditDetail(d)}
NS link · Δ />
</th> ))}
)}
{ev.suppliers.map(s => {
// User 2026-05-07: dùng tên NCC (master) thay vì displayName.
// Khi NCC là winner (selected ở Section 2.a) → column highlight
// emerald để cell giá ăn theo màu xanh (visual trace winner).
const isWinner = ev.selectedSupplierId === s.supplierId
return (
<th
key={s.id}
className={cn(
'border-r border-slate-200 px-2 py-2 text-right',
isWinner && 'bg-emerald-50 text-emerald-700',
)}
title={s.displayName ?? undefined}
>
{isWinner && '✓ '}{s.supplierName}
</th>
)
})}
{!readOnly && <th className="px-2 py-2"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.details.map(d => (
<tr key={d.id}>
<td className="sticky left-0 z-10 border-r border-slate-200 bg-white px-2 py-2">
<div className="font-medium text-slate-900">{d.groupCode} {d.noiDung}</div>
<div className="text-[10px] text-slate-500">{d.groupName} · {d.donViTinh ?? ''}</div>
</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{d.khoiLuongNganSach}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.donGiaNganSach)}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.thanhTienNganSach)}</td>
{showBudgetCol && (() => {
const bgValue = budgetRowMap.get(`${d.groupCode}|${d.itemCode ?? ''}`)
if (bgValue == null)
return <td className="border-r border-slate-200 bg-amber-50/40 px-2 py-2 text-right text-slate-300"></td>
const delta = d.thanhTienNganSach - bgValue
return (
<td
className={cn(
'border-r border-slate-200 bg-amber-50/40 px-2 py-2 text-right font-mono',
delta > 0 && 'text-red-600',
delta < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}
title={`Ngân sách: ${fmtMoney(bgValue)} · Δ ${delta > 0 ? '+' : ''}${fmtMoney(delta)}`}
>
{fmtMoney(bgValue)}
<div className="text-[10px]">{delta === 0 ? '=' : (delta > 0 ? `+${fmtMoney(delta)}` : fmtMoney(delta))}</div>
</td>
)
})()}
{ev.suppliers.map(s => {
const q = quoteKey(d.id, s.id)
// Winner NCC (selected ở Section 2.a) → cell ăn theo màu xanh
// emerald (user 2026-05-07). isSelected per-quote checkbox bỏ
// (đã consolidate winner ở Section 2.a NccSelectorRow).
const isWinnerColumn = ev.selectedSupplierId === s.supplierId
return (
<td
key={s.id}
onClick={readOnly ? undefined : () => 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',
isWinnerColumn && 'bg-emerald-50 font-semibold text-emerald-700',
)}
>
{q ? fmtMoney(q.thanhTien) : <span className="text-slate-300"></span>}
</td>
)
})}
{!readOnly && (
<td className="px-2 py-2">
<div className="flex gap-1">
<button onClick={() => setEditDetail(d)} className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100">
<Pencil className="h-3 w-3" />
</button>
<button onClick={() => { if (confirm('Xóa hạng mục?')) removeDetail.mutate(d.id) }} className="rounded px-1 py-0.5 text-red-500 hover:bg-red-50">
<Trash2 className="h-3 w-3" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
{showBudgetCol && (
<tfoot className="border-t-2 border-slate-300 bg-slate-50 text-xs font-semibold">
<tr>
<td className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-right">Tổng:</td>
<td className="border-r border-slate-200"></td>
<td className="border-r border-slate-200"></td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(totalPeNganSach)}</td>
<td className="border-r border-slate-200 bg-amber-50 px-2 py-2 text-right font-mono">
{fmtMoney(totalBudget)}
{(() => {
const delta = totalPeNganSach - totalBudget
return (
<div className={cn(
'text-[10px]',
delta > 0 && 'text-red-600',
delta < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
{delta === 0 ? 'khớp ngân sách' : delta > 0 ? `vượt +${fmtMoney(delta)}` : `dưới ${fmtMoney(delta)}`}
</div>
)
})()}
</td>
{ev.suppliers.map(s => <td key={s.id} className="border-r border-slate-200" />)}
{!readOnly && <td />}
</tr>
</tfoot>
)}
</table>
</div> </div>
)} )}
{addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />} {addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />}
{editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />} {editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />}
</div>
)
}
// 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<string, number>
showBudgetCol: boolean
onEditDetail: () => void
}) {
const qc = useQueryClient()
const [expanded, setExpanded] = useState(true)
const [addNccOpen, setAddNccOpen] = useState(false)
const [editNccRow, setEditNccRow] = useState<PeSupplier | null>(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 (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* Header row — hạng mục info + actions */}
<div className="flex items-start gap-3 border-b border-slate-100 p-3">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="mt-0.5 text-slate-400 hover:text-slate-700"
title={expanded ? 'Đóng' : 'Mở'}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-900">
<span className="font-mono text-[12px] text-slate-500 mr-2">{detail.groupCode}</span>
{detail.noiDung}
</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{detail.groupName}{detail.donViTinh ? ` · ĐVT: ${detail.donViTinh}` : ''}
</div>
</div>
<div className="flex flex-shrink-0 items-start gap-4 text-right text-xs">
<div>
<div className="text-[10px] uppercase text-slate-400">KL</div>
<div className="font-mono">{detail.khoiLuongNganSach}</div>
</div>
<div>
<div className="text-[10px] uppercase text-slate-400">ĐG ngân sách</div>
<div className="font-mono">{fmtMoney(detail.donGiaNganSach)}</div>
</div>
<div>
<div className="text-[10px] uppercase text-slate-400">Thành tiền NS</div>
<div className="font-mono font-semibold">{fmtMoney(detail.thanhTienNganSach)}</div>
</div>
{showBudgetCol && bgValue != null && (
<div className="border-l border-slate-200 pl-3">
<div className="text-[10px] uppercase text-slate-400">NS link</div>
<div className="font-mono text-[11px]">{fmtMoney(bgValue)}</div>
<div className={cn(
'font-mono text-[10px]',
delta! > 0 && 'text-red-600',
delta! < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
Δ {delta! > 0 ? '+' : ''}{fmtMoney(delta!)}
</div>
</div>
)}
</div>
{!readOnly && (
<div className="flex flex-shrink-0 gap-1">
<button
onClick={onEditDetail}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa hạng mục"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { 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"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* Expand panel — NCC tham gia + báo giá inline */}
{expanded && (
<div className="p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[11px] uppercase tracking-wide text-slate-500">
NCC tham gia ({ev.suppliers.length})
</div>
{!readOnly && (
<Button variant="ghost" onClick={() => setAddNccOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3 w-3" /> Thêm NCC
</Button>
)}
</div>
{ev.suppliers.length === 0 ? (
<p className="text-xs text-slate-500">
{readOnly ? 'Chưa có NCC tham gia.' : 'Chưa có NCC. Thêm NCC để nhập báo giá.'}
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full border border-slate-200 text-xs">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="border-r border-slate-200 px-2 py-1.5 text-left">NCC</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-left">Liên hệ</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-left">Điều khoản TT</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-left">File báo giá</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-right">ĐG chưa VAT</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-right">ĐG VAT</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-right">Thành tiền</th>
{!readOnly && <th className="px-2 py-1.5"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{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 (
<tr key={s.id} className={cn('align-top', isWinner && 'bg-emerald-50/60')}>
<td className="border-r border-slate-200 px-2 py-1.5">
<div className="font-medium text-slate-900">
{isWinner && <span className="text-emerald-700"> </span>}{s.supplierName}
</div>
{s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>}
{s.note && <div className="text-[10px] text-amber-600">{s.note}</div>}
</td>
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px] text-slate-600">
{s.contactName && <div>{s.contactName}</div>}
{s.contactPhone && <div>{s.contactPhone}</div>}
{s.contactEmail && <div className="truncate" title={s.contactEmail}>{s.contactEmail}</div>}
{!s.contactName && !s.contactPhone && !s.contactEmail && <span className="text-slate-300"></span>}
</td>
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px]">
{s.paymentTermText ?? <span className="text-slate-300"></span>}
</td>
<td className="border-r border-slate-200 px-2 py-1.5">
<SupplierAttachmentsCell
evaluationId={ev.id}
supplierRowId={s.id}
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
readOnly={readOnly}
/>
</td>
<td
onClick={readOnly ? undefined : openQuote}
className={cn('border-r border-slate-200 px-2 py-1.5 text-right font-mono', cellHover)}
>
{q ? fmtMoney(q.chuaVat) : <span className="text-slate-300"></span>}
</td>
<td
onClick={readOnly ? undefined : openQuote}
className={cn('border-r border-slate-200 px-2 py-1.5 text-right font-mono', cellHover)}
>
{q ? fmtMoney(q.bgVat) : <span className="text-slate-300"></span>}
</td>
<td
onClick={readOnly ? undefined : openQuote}
className={cn(
'border-r border-slate-200 px-2 py-1.5 text-right font-mono font-semibold',
isWinner && 'text-emerald-700',
cellHover,
)}
title={!readOnly ? 'Click để nhập / sửa báo giá' : undefined}
>
{q ? fmtMoney(q.thanhTien) : <span className="text-slate-300"></span>}
</td>
{!readOnly && (
<td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5">
<button
onClick={() => 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'}
>
<Check className="h-3 w-3" />
</button>
{!isWinner && (
<button
onClick={() => setEditNccRow(s)}
className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa thông tin NCC"
>
<Pencil className="h-3 w-3" />
</button>
)}
{canDelete ? (
<button
onClick={() => { 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"
>
<Trash2 className="h-3 w-3" />
</button>
) : !isWinner && hasQuotes && (
<span
className="rounded px-1 py-0.5 text-slate-300 cursor-not-allowed"
title="NCC đã có báo giá — xóa báo giá trước rồi mới xóa NCC"
>
<Trash2 className="h-3 w-3" />
</span>
)}
</div>
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)}
{addNccOpen && <AddSupplierDialog evaluationId={ev.id} onClose={() => setAddNccOpen(false)} />}
{editNccRow && <EditSupplierDialog evaluationId={ev.id} row={editNccRow} onClose={() => setEditNccRow(null)} />}
{quoteEdit && ( {quoteEdit && (
<QuoteDialog <QuoteDialog
evaluationId={ev.id} evaluationId={ev.id}
detailId={quoteEdit.detail.id} detailId={detail.id}
supplierRowId={quoteEdit.supplier.id} supplierRowId={quoteEdit.supplier.id}
supplierName={quoteEdit.supplier.supplierName} supplierName={quoteEdit.supplier.supplierName}
itemName={quoteEdit.detail.noiDung} itemName={detail.noiDung}
khoiLuong={quoteEdit.detail.khoiLuongThiCong || quoteEdit.detail.khoiLuongNganSach} khoiLuong={detail.khoiLuongThiCong || detail.khoiLuongNganSach}
existing={quoteEdit.existing} existing={quoteEdit.existing}
onClose={() => setQuoteEdit(null)} onClose={() => setQuoteEdit(null)}
/> />

View File

@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Check, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react' import { Check, ChevronDown, ChevronRight, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
@ -161,23 +161,20 @@ export function PeDetailTabs({
</div> </div>
<div className="divide-y divide-slate-200"> <div className="divide-y divide-slate-200">
{/* Section order (Session 20): Hạng mục lên đầu sau Thông tin gói thầu. {/* Section layout (Session 20 Chunk B): Hạng mục nested expand chứa NCC
BE auto-tạo 1 hạng mục mặc định (tên = TenGoiThau, giá trị = ngân sách) (tầng 1 = hạng mục, tầng 2 = NCC tham gia + báo giá inline). NCC
khi Create. NCC tham gia tạm giữ riêng — Chunk B sẽ gộp NCC nested tham gia section riêng bỏ — gộp vào Section 2 expand panel. Tên
expand dưới mỗi hạng mục. */} hạng mục + giá trị auto từ gói thầu (Chunk A BE seed). */}
<Section title="1. Thông tin gói thầu"> <Section title="1. Thông tin gói thầu">
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} /> <InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
</Section> </Section>
<Section title={`2. Hạng mục + Báo giá (${evaluation.details.length})`}> <Section title={`2. Hạng mục + Báo giá NCC (${evaluation.details.length} hạng mục · ${evaluation.suppliers.length} NCC)`}>
<ItemsTab ev={evaluation} readOnly={readOnly} /> <ItemsTab ev={evaluation} readOnly={readOnly} />
</Section> </Section>
<Section title="3. Chọn NCC / TP"> <Section title="3. Chọn NCC / TP thắng thầu">
<ChonNccSection ev={evaluation} readOnly={readOnly} /> <ChonNccSection ev={evaluation} readOnly={readOnly} />
</Section> </Section>
<Section title={`4. NCC / TP tham gia (${evaluation.suppliers.length})`}> <Section title="4. Ý kiến cấp duyệt (sign-off theo workflow)">
<SuppliersTab ev={evaluation} readOnly={readOnly} />
</Section>
<Section title="5. Ý kiến cấp duyệt (sign-off theo workflow)">
{mode === 'workspace' && ( {mode === 'workspace' && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800"> <div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
Ý kiến + chữ auto đng bộ khi NV duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ . Ý kiến + chữ auto đng bộ khi NV duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
@ -1037,139 +1034,9 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
) )
} }
// ===== Tab: NCC ===== // Session 20 Chunk B: SuppliersTab function bỏ — NCC list giờ render nested
function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { // trong HangMucCard (expand panel mỗi hạng mục). 2 dialog Add/Edit Supplier
const qc = useQueryClient() // vẫn giữ vì HangMucCard call lại.
const [open, setOpen] = useState(false)
const [editRow, setEditRow] = useState<PeSupplier | null>(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 (
<div>
{!readOnly && (
<div className="mb-3 flex justify-end">
<Button onClick={() => setOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Thêm NCC
</Button>
</div>
)}
{ev.suppliers.length === 0 ? (
<p className="text-sm text-slate-500">
{readOnly ? 'Chưa có NCC.' : 'Chưa có NCC. Thêm NCC để bắt đầu so sánh giá.'}
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 text-left">NCC</th>
<th className="px-3 py-2 text-left">Liên hệ</th>
<th className="px-3 py-2 text-left">Điều khoản TT</th>
<th className="px-3 py-2 text-left">File đính kèm</th>
{!readOnly && <th className="px-3 py-2"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.suppliers.map(s => (
<tr key={s.id} className={cn('align-top', ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
<td className="px-3 py-2">
<div className="font-medium text-slate-900">{s.supplierName}</div>
{s.displayName && <div className="text-[11px] text-slate-500">{s.displayName}</div>}
{s.note && <div className="mt-0.5 text-[11px] text-amber-600">{s.note}</div>}
{readOnly && ev.selectedSupplierId === s.supplierId && (
<div className="mt-0.5 text-[11px] font-medium text-emerald-700"> NCC đưc chọn</div>
)}
</td>
<td className="px-3 py-2 text-[12px] text-slate-600">
{s.contactName && <div>{s.contactName}</div>}
{s.contactPhone && <div>{s.contactPhone}</div>}
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
</td>
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</td>
<td className="px-3 py-2">
<SupplierAttachmentsCell
evaluationId={ev.id}
supplierRowId={s.id}
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
readOnly={readOnly}
/>
</td>
{!readOnly && (() => {
// User 2026-05-07: NCC đã được chọn (winner) → KHÔNG cho
// sửa/xóa (tránh thay đổi NCC đã chốt). Chỉ hiển thị
// checkmark active state.
// User 2026-05-07 (B11+): NCC đã có hạng mục báo giá (quotes
// entered in Section 4) → KHÔNG cho xóa (tránh mất báo giá đã nhập).
const isWinner = ev.selectedSupplierId === s.supplierId
const hasQuotes = ev.details.some(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes
return (
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => setWinner.mutate(s.supplierId)}
className={cn(
'rounded px-1.5 py-0.5 text-[11px]',
isWinner
? 'bg-emerald-100 text-emerald-700'
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
)}
title={isWinner ? 'NCC đã được chọn (winner)' : 'Chọn NCC thắng'}
>
<Check className="h-3.5 w-3.5" />
</button>
{!isWinner && (
<button
onClick={() => setEditRow(s)}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{canDelete ? (
<button
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : !isWinner && hasQuotes && (
<span
className="rounded px-1.5 py-0.5 text-slate-300 cursor-not-allowed"
title="NCC đã có báo giá ở Section 4 — xóa báo giá trước rồi mới xóa NCC"
>
<Trash2 className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
)
})()}
</tr>
))}
</tbody>
</table>
</div>
)}
{open && <AddSupplierDialog evaluationId={ev.id} onClose={() => setOpen(false)} />}
{editRow && <EditSupplierDialog evaluationId={ev.id} row={editRow} onClose={() => setEditRow(null)} />}
</div>
)
}
function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; onClose: () => void }) { function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; onClose: () => void }) {
const qc = useQueryClient() const qc = useQueryClient()
@ -1263,24 +1130,14 @@ function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: stri
) )
} }
// ===== Tab: Hạng mục + Báo giá (matrix) ===== // ===== 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 }) { function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient()
const [addOpen, setAddOpen] = useState(false) const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null) const [editDetail, setEditDetail] = useState<PeDetailRow | null>(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
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row. // Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
// Match key: groupCode|itemCode (case-sensitive match; itemCode null cho phép).
const budgetBundle = useQuery({ const budgetBundle = useQuery({
queryKey: ['budget-detail-for-pe', ev.budgetId], queryKey: ['budget-detail-for-pe', ev.budgetId],
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>( queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
@ -1295,18 +1152,13 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
return m return m
})() })()
const showBudgetCol = !!ev.budgetId const showBudgetCol = !!ev.budgetId
const totalPeNganSach = ev.details.reduce((sum, d) => sum + d.thanhTienNganSach, 0)
const totalBudget = budgetBundle.data?.tongNganSach ?? 0
return ( return (
<div> <div>
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
{ev.suppliers.length === 0 {ev.details.length} hạng mục · {ev.suppliers.length} NCC tham gia
? (readOnly ? 'Chưa có NCC tham gia.' : 'Thêm NCC ở tab "NCC" trước khi nhập báo giá.') {!readOnly && ' — mở hạng mục để thêm NCC + 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á.`}
</p> </p>
{!readOnly && ( {!readOnly && (
<Button onClick={() => setAddOpen(true)} className="gap-1.5 text-xs"> <Button onClick={() => setAddOpen(true)} className="gap-1.5 text-xs">
@ -1318,147 +1170,285 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
{ev.details.length === 0 ? ( {ev.details.length === 0 ? (
<p className="text-sm text-slate-500">Chưa hạng mục.</p> <p className="text-sm text-slate-500">Chưa hạng mục.</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="space-y-3">
<table className="min-w-full border border-slate-200 text-xs"> {ev.details.map(d => (
<thead className="bg-slate-50 text-slate-600"> <HangMucCard
<tr> key={d.id}
<th className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-left">Hạng mục</th> detail={d}
<th className="border-r border-slate-200 px-2 py-2 text-right">KL</th> ev={ev}
<th className="border-r border-slate-200 px-2 py-2 text-right">ĐG ngân sách</th> readOnly={readOnly}
<th className="border-r border-slate-200 px-2 py-2 text-right">TT ngân sách</th> budgetRowMap={budgetRowMap}
{showBudgetCol && ( showBudgetCol={showBudgetCol}
<th className="border-r border-slate-200 bg-amber-50 px-2 py-2 text-right" title="So với ngân sách đã link"> onEditDetail={() => setEditDetail(d)}
NS link · Δ />
</th> ))}
)}
{ev.suppliers.map(s => {
// User 2026-05-07: dùng tên NCC (master) thay vì displayName.
// Khi NCC là winner (selected ở Section 2.a) → column highlight
// emerald để cell giá ăn theo màu xanh (visual trace winner).
const isWinner = ev.selectedSupplierId === s.supplierId
return (
<th
key={s.id}
className={cn(
'border-r border-slate-200 px-2 py-2 text-right',
isWinner && 'bg-emerald-50 text-emerald-700',
)}
title={s.displayName ?? undefined}
>
{isWinner && '✓ '}{s.supplierName}
</th>
)
})}
{!readOnly && <th className="px-2 py-2"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.details.map(d => (
<tr key={d.id}>
<td className="sticky left-0 z-10 border-r border-slate-200 bg-white px-2 py-2">
<div className="font-medium text-slate-900">{d.groupCode} {d.noiDung}</div>
<div className="text-[10px] text-slate-500">{d.groupName} · {d.donViTinh ?? ''}</div>
</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{d.khoiLuongNganSach}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.donGiaNganSach)}</td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.thanhTienNganSach)}</td>
{showBudgetCol && (() => {
const bgValue = budgetRowMap.get(`${d.groupCode}|${d.itemCode ?? ''}`)
if (bgValue == null)
return <td className="border-r border-slate-200 bg-amber-50/40 px-2 py-2 text-right text-slate-300"></td>
const delta = d.thanhTienNganSach - bgValue
return (
<td
className={cn(
'border-r border-slate-200 bg-amber-50/40 px-2 py-2 text-right font-mono',
delta > 0 && 'text-red-600',
delta < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}
title={`Ngân sách: ${fmtMoney(bgValue)} · Δ ${delta > 0 ? '+' : ''}${fmtMoney(delta)}`}
>
{fmtMoney(bgValue)}
<div className="text-[10px]">{delta === 0 ? '=' : (delta > 0 ? `+${fmtMoney(delta)}` : fmtMoney(delta))}</div>
</td>
)
})()}
{ev.suppliers.map(s => {
const q = quoteKey(d.id, s.id)
// Winner NCC (selected ở Section 2.a) → cell ăn theo màu xanh
// emerald (user 2026-05-07). isSelected per-quote checkbox bỏ
// (đã consolidate winner ở Section 2.a NccSelectorRow).
const isWinnerColumn = ev.selectedSupplierId === s.supplierId
return (
<td
key={s.id}
onClick={readOnly ? undefined : () => 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',
isWinnerColumn && 'bg-emerald-50 font-semibold text-emerald-700',
)}
>
{q ? fmtMoney(q.thanhTien) : <span className="text-slate-300"></span>}
</td>
)
})}
{!readOnly && (
<td className="px-2 py-2">
<div className="flex gap-1">
<button onClick={() => setEditDetail(d)} className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100">
<Pencil className="h-3 w-3" />
</button>
<button onClick={() => { if (confirm('Xóa hạng mục?')) removeDetail.mutate(d.id) }} className="rounded px-1 py-0.5 text-red-500 hover:bg-red-50">
<Trash2 className="h-3 w-3" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
{showBudgetCol && (
<tfoot className="border-t-2 border-slate-300 bg-slate-50 text-xs font-semibold">
<tr>
<td className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-right">Tổng:</td>
<td className="border-r border-slate-200"></td>
<td className="border-r border-slate-200"></td>
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(totalPeNganSach)}</td>
<td className="border-r border-slate-200 bg-amber-50 px-2 py-2 text-right font-mono">
{fmtMoney(totalBudget)}
{(() => {
const delta = totalPeNganSach - totalBudget
return (
<div className={cn(
'text-[10px]',
delta > 0 && 'text-red-600',
delta < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
{delta === 0 ? 'khớp ngân sách' : delta > 0 ? `vượt +${fmtMoney(delta)}` : `dưới ${fmtMoney(delta)}`}
</div>
)
})()}
</td>
{ev.suppliers.map(s => <td key={s.id} className="border-r border-slate-200" />)}
{!readOnly && <td />}
</tr>
</tfoot>
)}
</table>
</div> </div>
)} )}
{addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />} {addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />}
{editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />} {editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />}
</div>
)
}
// 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<string, number>
showBudgetCol: boolean
onEditDetail: () => void
}) {
const qc = useQueryClient()
const [expanded, setExpanded] = useState(true)
const [addNccOpen, setAddNccOpen] = useState(false)
const [editNccRow, setEditNccRow] = useState<PeSupplier | null>(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 (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* Header row — hạng mục info + actions */}
<div className="flex items-start gap-3 border-b border-slate-100 p-3">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="mt-0.5 text-slate-400 hover:text-slate-700"
title={expanded ? 'Đóng' : 'Mở'}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-900">
<span className="font-mono text-[12px] text-slate-500 mr-2">{detail.groupCode}</span>
{detail.noiDung}
</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{detail.groupName}{detail.donViTinh ? ` · ĐVT: ${detail.donViTinh}` : ''}
</div>
</div>
<div className="flex flex-shrink-0 items-start gap-4 text-right text-xs">
<div>
<div className="text-[10px] uppercase text-slate-400">KL</div>
<div className="font-mono">{detail.khoiLuongNganSach}</div>
</div>
<div>
<div className="text-[10px] uppercase text-slate-400">ĐG ngân sách</div>
<div className="font-mono">{fmtMoney(detail.donGiaNganSach)}</div>
</div>
<div>
<div className="text-[10px] uppercase text-slate-400">Thành tiền NS</div>
<div className="font-mono font-semibold">{fmtMoney(detail.thanhTienNganSach)}</div>
</div>
{showBudgetCol && bgValue != null && (
<div className="border-l border-slate-200 pl-3">
<div className="text-[10px] uppercase text-slate-400">NS link</div>
<div className="font-mono text-[11px]">{fmtMoney(bgValue)}</div>
<div className={cn(
'font-mono text-[10px]',
delta! > 0 && 'text-red-600',
delta! < 0 && 'text-emerald-600',
delta === 0 && 'text-slate-500',
)}>
Δ {delta! > 0 ? '+' : ''}{fmtMoney(delta!)}
</div>
</div>
)}
</div>
{!readOnly && (
<div className="flex flex-shrink-0 gap-1">
<button
onClick={onEditDetail}
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa hạng mục"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { 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"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* Expand panel — NCC tham gia + báo giá inline */}
{expanded && (
<div className="p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[11px] uppercase tracking-wide text-slate-500">
NCC tham gia ({ev.suppliers.length})
</div>
{!readOnly && (
<Button variant="ghost" onClick={() => setAddNccOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3 w-3" /> Thêm NCC
</Button>
)}
</div>
{ev.suppliers.length === 0 ? (
<p className="text-xs text-slate-500">
{readOnly ? 'Chưa có NCC tham gia.' : 'Chưa có NCC. Thêm NCC để nhập báo giá.'}
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full border border-slate-200 text-xs">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="border-r border-slate-200 px-2 py-1.5 text-left">NCC</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-left">Liên hệ</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-left">Điều khoản TT</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-left">File báo giá</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-right">ĐG chưa VAT</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-right">ĐG VAT</th>
<th className="border-r border-slate-200 px-2 py-1.5 text-right">Thành tiền</th>
{!readOnly && <th className="px-2 py-1.5"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{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 (
<tr key={s.id} className={cn('align-top', isWinner && 'bg-emerald-50/60')}>
<td className="border-r border-slate-200 px-2 py-1.5">
<div className="font-medium text-slate-900">
{isWinner && <span className="text-emerald-700"> </span>}{s.supplierName}
</div>
{s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>}
{s.note && <div className="text-[10px] text-amber-600">{s.note}</div>}
</td>
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px] text-slate-600">
{s.contactName && <div>{s.contactName}</div>}
{s.contactPhone && <div>{s.contactPhone}</div>}
{s.contactEmail && <div className="truncate" title={s.contactEmail}>{s.contactEmail}</div>}
{!s.contactName && !s.contactPhone && !s.contactEmail && <span className="text-slate-300"></span>}
</td>
<td className="border-r border-slate-200 px-2 py-1.5 text-[11px]">
{s.paymentTermText ?? <span className="text-slate-300"></span>}
</td>
<td className="border-r border-slate-200 px-2 py-1.5">
<SupplierAttachmentsCell
evaluationId={ev.id}
supplierRowId={s.id}
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
readOnly={readOnly}
/>
</td>
<td
onClick={readOnly ? undefined : openQuote}
className={cn('border-r border-slate-200 px-2 py-1.5 text-right font-mono', cellHover)}
>
{q ? fmtMoney(q.chuaVat) : <span className="text-slate-300"></span>}
</td>
<td
onClick={readOnly ? undefined : openQuote}
className={cn('border-r border-slate-200 px-2 py-1.5 text-right font-mono', cellHover)}
>
{q ? fmtMoney(q.bgVat) : <span className="text-slate-300"></span>}
</td>
<td
onClick={readOnly ? undefined : openQuote}
className={cn(
'border-r border-slate-200 px-2 py-1.5 text-right font-mono font-semibold',
isWinner && 'text-emerald-700',
cellHover,
)}
title={!readOnly ? 'Click để nhập / sửa báo giá' : undefined}
>
{q ? fmtMoney(q.thanhTien) : <span className="text-slate-300"></span>}
</td>
{!readOnly && (
<td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5">
<button
onClick={() => 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'}
>
<Check className="h-3 w-3" />
</button>
{!isWinner && (
<button
onClick={() => setEditNccRow(s)}
className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa thông tin NCC"
>
<Pencil className="h-3 w-3" />
</button>
)}
{canDelete ? (
<button
onClick={() => { 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"
>
<Trash2 className="h-3 w-3" />
</button>
) : !isWinner && hasQuotes && (
<span
className="rounded px-1 py-0.5 text-slate-300 cursor-not-allowed"
title="NCC đã có báo giá — xóa báo giá trước rồi mới xóa NCC"
>
<Trash2 className="h-3 w-3" />
</span>
)}
</div>
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)}
{addNccOpen && <AddSupplierDialog evaluationId={ev.id} onClose={() => setAddNccOpen(false)} />}
{editNccRow && <EditSupplierDialog evaluationId={ev.id} row={editNccRow} onClose={() => setEditNccRow(null)} />}
{quoteEdit && ( {quoteEdit && (
<QuoteDialog <QuoteDialog
evaluationId={ev.id} evaluationId={ev.id}
detailId={quoteEdit.detail.id} detailId={detail.id}
supplierRowId={quoteEdit.supplier.id} supplierRowId={quoteEdit.supplier.id}
supplierName={quoteEdit.supplier.supplierName} supplierName={quoteEdit.supplier.supplierName}
itemName={quoteEdit.detail.noiDung} itemName={detail.noiDung}
khoiLuong={quoteEdit.detail.khoiLuongThiCong || quoteEdit.detail.khoiLuongNganSach} khoiLuong={detail.khoiLuongThiCong || detail.khoiLuongNganSach}
existing={quoteEdit.existing} existing={quoteEdit.existing}
onClose={() => setQuoteEdit(null)} onClose={() => setQuoteEdit(null)}
/> />