[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
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:
@ -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ữ ký auto đồng bộ khi NV duyệt phiếu — vào menu “Duyệt” để ký.
|
Ý 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 =====
|
// 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 có hạng mục.</p>
|
<p className="text-sm text-slate-500">Chưa có 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 có 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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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ữ ký auto đồng bộ khi NV duyệt phiếu — vào menu “Duyệt” để ký.
|
Ý 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 =====
|
// 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 có hạng mục.</p>
|
<p className="text-sm text-slate-500">Chưa có 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 có 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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user