diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx
index 2d1689e..bae82d4 100644
--- a/fe-admin/src/components/pe/PeDetailTabs.tsx
+++ b/fe-admin/src/components/pe/PeDetailTabs.tsx
@@ -6,7 +6,7 @@ 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, 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 { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
@@ -161,23 +161,20 @@ export function PeDetailTabs({
- {/* Section order (Session 20): Hạng mục lên đầu sau Thông tin gói thầu.
- BE auto-tạo 1 hạng mục mặc định (tên = TenGoiThau, giá trị = ngân sách)
- khi Create. NCC tham gia tạm giữ riêng — Chunk B sẽ gộp NCC nested
- expand dưới mỗi hạng mục. */}
+ {/* 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ý.
@@ -1037,139 +1034,9 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
)
}
-// ===== 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 && (
-
-
setOpen(true)} className="gap-1.5 text-xs">
- Thêm NCC
-
-
- )}
- {ev.suppliers.length === 0 ? (
-
- {readOnly ? 'Chưa có NCC.' : 'Chưa có NCC. Thêm NCC để bắt đầu so sánh giá.'}
-
- ) : (
-
-
-
-
- NCC
- Liên hệ
- Điều khoản TT
- File đính kèm
- {!readOnly && }
-
-
-
- {ev.suppliers.map(s => (
-
-
- {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}
- />
-
- {!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 (
-
-
-
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'}
- >
-
-
- {!isWinner && (
-
setEditRow(s)}
- className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
- title="Sửa"
- >
-
-
- )}
- {canDelete ? (
-
{ 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"
- >
-
-
- ) : !isWinner && hasQuotes && (
-
-
-
- )}
-
-
- )
- })()}
-
- ))}
-
-
-
- )}
-
- {open &&
setOpen(false)} />}
- {editRow && setEditRow(null)} />}
-
- )
-}
+// 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()
@@ -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 }) {
- 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
// 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({
queryKey: ['budget-detail-for-pe', ev.budgetId],
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
})()
const showBudgetCol = !!ev.budgetId
- const totalPeNganSach = ev.details.reduce((sum, d) => sum + d.thanhTienNganSach, 0)
- const totalBudget = budgetBundle.data?.tongNganSach ?? 0
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á.`}
+ {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">
@@ -1318,147 +1170,285 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
{ev.details.length === 0 ? (
Chưa có hạng mục.
) : (
-
-
-
-
- Hạng mục
- KL
- ĐG ngân sách
- TT ngân sách
- {showBudgetCol && (
-
- NS link · Δ
-
- )}
- {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 (
-
- {isWinner && '✓ '}{s.supplierName}
-
- )
- })}
- {!readOnly && }
-
-
-
- {ev.details.map(d => (
-
-
- {d.groupCode} {d.noiDung}
- {d.groupName} · {d.donViTinh ?? ''}
-
- {d.khoiLuongNganSach}
- {fmtMoney(d.donGiaNganSach)}
- {fmtMoney(d.thanhTienNganSach)}
- {showBudgetCol && (() => {
- const bgValue = budgetRowMap.get(`${d.groupCode}|${d.itemCode ?? ''}`)
- if (bgValue == null)
- return —
- const delta = d.thanhTienNganSach - bgValue
- return (
- 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)}
- {delta === 0 ? '=' : (delta > 0 ? `+${fmtMoney(delta)}` : fmtMoney(delta))}
-
- )
- })()}
- {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 (
- 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) : — }
-
- )
- })}
- {!readOnly && (
-
-
-
setEditDetail(d)} className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100">
-
-
-
{ 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">
-
-
-
-
- )}
-
- ))}
-
- {showBudgetCol && (
-
-
- Tổng:
-
-
- {fmtMoney(totalPeNganSach)}
-
- {fmtMoney(totalBudget)}
- {(() => {
- const delta = totalPeNganSach - totalBudget
- return (
- 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)}`}
-
- )
- })()}
-
- {ev.suppliers.map(s => )}
- {!readOnly && }
-
-
- )}
-
+
+ {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)}
/>
diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx
index 2d1689e..bae82d4 100644
--- a/fe-user/src/components/pe/PeDetailTabs.tsx
+++ b/fe-user/src/components/pe/PeDetailTabs.tsx
@@ -6,7 +6,7 @@ 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, 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 { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
@@ -161,23 +161,20 @@ export function PeDetailTabs({
- {/* Section order (Session 20): Hạng mục lên đầu sau Thông tin gói thầu.
- BE auto-tạo 1 hạng mục mặc định (tên = TenGoiThau, giá trị = ngân sách)
- khi Create. NCC tham gia tạm giữ riêng — Chunk B sẽ gộp NCC nested
- expand dưới mỗi hạng mục. */}
+ {/* 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ý.
@@ -1037,139 +1034,9 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
)
}
-// ===== 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 && (
-
-
setOpen(true)} className="gap-1.5 text-xs">
- Thêm NCC
-
-
- )}
- {ev.suppliers.length === 0 ? (
-
- {readOnly ? 'Chưa có NCC.' : 'Chưa có NCC. Thêm NCC để bắt đầu so sánh giá.'}
-
- ) : (
-
-
-
-
- NCC
- Liên hệ
- Điều khoản TT
- File đính kèm
- {!readOnly && }
-
-
-
- {ev.suppliers.map(s => (
-
-
- {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}
- />
-
- {!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 (
-
-
-
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'}
- >
-
-
- {!isWinner && (
-
setEditRow(s)}
- className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
- title="Sửa"
- >
-
-
- )}
- {canDelete ? (
-
{ 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"
- >
-
-
- ) : !isWinner && hasQuotes && (
-
-
-
- )}
-
-
- )
- })()}
-
- ))}
-
-
-
- )}
-
- {open &&
setOpen(false)} />}
- {editRow && setEditRow(null)} />}
-
- )
-}
+// 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()
@@ -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 }) {
- 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
// 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({
queryKey: ['budget-detail-for-pe', ev.budgetId],
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
})()
const showBudgetCol = !!ev.budgetId
- const totalPeNganSach = ev.details.reduce((sum, d) => sum + d.thanhTienNganSach, 0)
- const totalBudget = budgetBundle.data?.tongNganSach ?? 0
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á.`}
+ {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">
@@ -1318,147 +1170,285 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
{ev.details.length === 0 ? (
Chưa có hạng mục.
) : (
-
-
-
-
- Hạng mục
- KL
- ĐG ngân sách
- TT ngân sách
- {showBudgetCol && (
-
- NS link · Δ
-
- )}
- {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 (
-
- {isWinner && '✓ '}{s.supplierName}
-
- )
- })}
- {!readOnly && }
-
-
-
- {ev.details.map(d => (
-
-
- {d.groupCode} {d.noiDung}
- {d.groupName} · {d.donViTinh ?? ''}
-
- {d.khoiLuongNganSach}
- {fmtMoney(d.donGiaNganSach)}
- {fmtMoney(d.thanhTienNganSach)}
- {showBudgetCol && (() => {
- const bgValue = budgetRowMap.get(`${d.groupCode}|${d.itemCode ?? ''}`)
- if (bgValue == null)
- return —
- const delta = d.thanhTienNganSach - bgValue
- return (
- 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)}
- {delta === 0 ? '=' : (delta > 0 ? `+${fmtMoney(delta)}` : fmtMoney(delta))}
-
- )
- })()}
- {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 (
- 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) : — }
-
- )
- })}
- {!readOnly && (
-
-
-
setEditDetail(d)} className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100">
-
-
-
{ 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">
-
-
-
-
- )}
-
- ))}
-
- {showBudgetCol && (
-
-
- Tổng:
-
-
- {fmtMoney(totalPeNganSach)}
-
- {fmtMoney(totalBudget)}
- {(() => {
- const delta = totalPeNganSach - totalBudget
- return (
- 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)}`}
-
- )
- })()}
-
- {ev.suppliers.map(s => )}
- {!readOnly && }
-
-
- )}
-
+
+ {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)}
/>