[CLAUDE] FE PE: restructure InfoTab theo spec PHIẾU TRÌNH KÝ CHỌN TP/NCC
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m3s

Match form chính thức 4 section đánh số:

1. Thông tin gói thầu — chỉ a. Tên gói thầu + b. Dự án (Địa điểm + Mô tả compact bên dưới nếu có).

2. Chọn NCC / TP — đúng a/b/c/d:
   a. NCC/TP được chọn — selectedSupplierName badge xanh
   b. Ngân sách — link Budget với mã + tên + tổng
   c. Giá chào thầu — tự compute = sum quotes của winner supplier (filter quotes.purchaseEvaluationSupplierId === winnerRowId)
   d. Bản so sánh — embed GeneralAttachmentsSection (attachments không gắn supplier-row, purpose=ComparisonTable)
   + ĐKTT + HĐ kế thừa link bonus
   + Banner emerald 'Tạo HĐ từ phiếu' khi DaDuyet + chưa có Contract

3. NCC/TP tham gia — section riêng giữ table 5 cột (NCC/Liên hệ/ĐKTT/File/Action — nhiều info hơn spec table 3 cột, useful cho UX web).

4. Hạng mục + Báo giá — matrix với cột 'NS link · Δ' + footer aggregate (giữ nguyên).

Side change:
- FormRow helper mới (label 176px + value flex) thay cho dl grid 2-col cũ — match style form giấy
- Drop Field helper cũ (now unused)
- InfoTab signature đổi: bỏ readOnly param (chỉ display, action move sang ChonNccSection)

TS build pass cả 2 app. Mirror fe-user identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-29 11:02:45 +07:00
parent 61e5d4d503
commit 7e36241db9
2 changed files with 196 additions and 90 deletions

View File

@ -95,22 +95,19 @@ export function PeDetailTabs({
</div> </div>
<div className="divide-y divide-slate-200"> <div className="divide-y divide-slate-200">
<Section title="Thông tin"> {/* Section 1 — đúng spec form FO-PHIẾU TRÌNH KÝ CHỌN TP/NCC */}
<InfoTab ev={evaluation} readOnly={readOnly} /> <Section title="1. Thông tin gói thầu">
<InfoTab ev={evaluation} />
</Section> </Section>
<Section title={`NCC tham gia (${evaluation.suppliers.length})`}> <Section title="2. Chọn NCC / TP">
<ChonNccSection ev={evaluation} readOnly={readOnly} />
</Section>
<Section title={`3. NCC / TP tham gia (${evaluation.suppliers.length})`}>
<SuppliersTab ev={evaluation} readOnly={readOnly} /> <SuppliersTab ev={evaluation} readOnly={readOnly} />
</Section> </Section>
<Section title={`Hạng mục + Báo giá (${evaluation.details.length})`}> <Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
<ItemsTab ev={evaluation} readOnly={readOnly} /> <ItemsTab ev={evaluation} readOnly={readOnly} />
</Section> </Section>
<Section title="Bảng so sánh (file tổng)">
<GeneralAttachmentsSection
evaluationId={evaluation.id}
attachments={evaluation.attachments.filter(a => a.purchaseEvaluationSupplierId === null)}
readOnly={readOnly}
/>
</Section>
</div> </div>
</div> </div>
) )
@ -145,35 +142,90 @@ export function PeHistorySection({ ev }: { ev: PeDetailBundle }) {
) )
} }
// ===== Tab: Thông tin ===== // ===== Section 1 — Thông tin gói thầu (spec: a. Tên gói thầu / b. Dự án) =====
function InfoTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { function InfoTab({ ev }: { ev: PeDetailBundle }) {
return (
<dl className="space-y-2 text-sm">
<FormRow label="a. Tên gói thầu" value={ev.tenGoiThau} />
<FormRow label="b. Dự án" value={ev.projectName} />
{(ev.diaDiem || ev.moTa) && (
<div className="mt-3 rounded bg-slate-50 px-3 py-2 text-[12px] text-slate-600">
{ev.diaDiem && <div><span className="text-slate-400">Đa điểm:</span> {ev.diaDiem}</div>}
{ev.moTa && <div><span className="text-slate-400"> tả:</span> {ev.moTa}</div>}
</div>
)}
</dl>
)
}
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
// c. Giá chào thầu = sum quotes của NCC được chọn (winner)
const winnerSupplierRowId = ev.selectedSupplierId
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
: null
const giaChaoThau = winnerSupplierRowId
? ev.details
.flatMap(d => d.quotes)
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
.reduce((sum, q) => sum + q.thanhTien, 0)
: null
// d. Bản so sánh — attachments với purpose=ComparisonTable hoặc supplier-row null
const banSoSanhAttachments = ev.attachments.filter(
a => a.purchaseEvaluationSupplierId === null,
)
return ( return (
<div className="space-y-4"> <div className="space-y-3">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm"> <FormRow
<Field label="Tên gói thầu" value={ev.tenGoiThau} /> label="a. NCC / TP được chọn"
<Field label="Dự án" value={ev.projectName} /> value={ev.selectedSupplierName ? (
<Field label="Địa điểm" value={ev.diaDiem ?? '—'} /> <span className="font-medium text-emerald-700"> {ev.selectedSupplierName}</span>
<Field label="Mô tả" value={ev.moTa ?? '—'} /> ) : <span className="text-slate-400"> (chưa chọn)</span>}
<Field label="NCC được chọn" value={ev.selectedSupplierName ?? '—'} /> />
<Field label="Điều khoản thanh toán" value={ev.paymentTerms ?? '—'} /> <FormRow
<Field label="b. Ngân sách"
label="Ngân sách"
value={ev.budget ? ( value={ev.budget ? (
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline"> <a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span> <span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
{' · '} {' · '}{ev.budget.tenNganSach}
{ev.budget.tenNganSach} {' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
{' · '}
<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a> </a>
) : <span className="text-slate-400"> (chưa link)</span>} ) : <span className="text-slate-400"> (chưa link)</span>}
/> />
{ev.contractId && ( <FormRow
<Field label="HĐ kế thừa" value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>} /> label="c. Giá chào thầu"
value={giaChaoThau != null ? (
<span className="font-semibold text-slate-900">{giaChaoThau.toLocaleString('vi-VN')} đ</span>
) : <span className="text-slate-400"> (chưa chọn NCC / chưa nhập báo giá)</span>}
/>
<div>
<div className="flex gap-3">
<span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bản so sánh</span>
<div className="min-w-0 flex-1">
<GeneralAttachmentsSection
evaluationId={ev.id}
attachments={banSoSanhAttachments}
readOnly={readOnly}
/>
</div>
</div>
</div>
{ev.paymentTerms && (
<FormRow label="Điều khoản thanh toán" value={<span className="whitespace-pre-wrap">{ev.paymentTerms}</span>} />
)} )}
</dl> {ev.contractId && (
<FormRow
label="HĐ kế thừa"
value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>}
/>
)}
{canCreateContract && ( {canCreateContract && (
<div className="rounded border border-emerald-200 bg-emerald-50 p-3"> <div className="rounded border border-emerald-200 bg-emerald-50 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@ -191,6 +243,16 @@ function InfoTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: bool
) )
} }
// Form row: label cố định 176px (w-44) bên trái + value bên phải (giống spec).
function FormRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
<dt className="w-44 shrink-0 text-[12px] text-slate-500">{label}</dt>
<dd className="min-w-0 flex-1 text-slate-800">{value}</dd>
</div>
)
}
function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) { function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) {
const navigate = useNavigate() const navigate = useNavigate()
const [form, setForm] = useState({ const [form, setForm] = useState({
@ -253,15 +315,6 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
) )
} }
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] uppercase tracking-wide text-slate-400">{label}</dt>
<dd className="mt-0.5 text-slate-800">{value}</dd>
</div>
)
}
// ===== Tab: NCC ===== // ===== Tab: NCC =====
function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient() const qc = useQueryClient()

View File

@ -95,22 +95,19 @@ export function PeDetailTabs({
</div> </div>
<div className="divide-y divide-slate-200"> <div className="divide-y divide-slate-200">
<Section title="Thông tin"> {/* Section 1 — đúng spec form FO-PHIẾU TRÌNH KÝ CHỌN TP/NCC */}
<InfoTab ev={evaluation} readOnly={readOnly} /> <Section title="1. Thông tin gói thầu">
<InfoTab ev={evaluation} />
</Section> </Section>
<Section title={`NCC tham gia (${evaluation.suppliers.length})`}> <Section title="2. Chọn NCC / TP">
<ChonNccSection ev={evaluation} readOnly={readOnly} />
</Section>
<Section title={`3. NCC / TP tham gia (${evaluation.suppliers.length})`}>
<SuppliersTab ev={evaluation} readOnly={readOnly} /> <SuppliersTab ev={evaluation} readOnly={readOnly} />
</Section> </Section>
<Section title={`Hạng mục + Báo giá (${evaluation.details.length})`}> <Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
<ItemsTab ev={evaluation} readOnly={readOnly} /> <ItemsTab ev={evaluation} readOnly={readOnly} />
</Section> </Section>
<Section title="Bảng so sánh (file tổng)">
<GeneralAttachmentsSection
evaluationId={evaluation.id}
attachments={evaluation.attachments.filter(a => a.purchaseEvaluationSupplierId === null)}
readOnly={readOnly}
/>
</Section>
</div> </div>
</div> </div>
) )
@ -145,35 +142,90 @@ export function PeHistorySection({ ev }: { ev: PeDetailBundle }) {
) )
} }
// ===== Tab: Thông tin ===== // ===== Section 1 — Thông tin gói thầu (spec: a. Tên gói thầu / b. Dự án) =====
function InfoTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { function InfoTab({ ev }: { ev: PeDetailBundle }) {
return (
<dl className="space-y-2 text-sm">
<FormRow label="a. Tên gói thầu" value={ev.tenGoiThau} />
<FormRow label="b. Dự án" value={ev.projectName} />
{(ev.diaDiem || ev.moTa) && (
<div className="mt-3 rounded bg-slate-50 px-3 py-2 text-[12px] text-slate-600">
{ev.diaDiem && <div><span className="text-slate-400">Đa điểm:</span> {ev.diaDiem}</div>}
{ev.moTa && <div><span className="text-slate-400"> tả:</span> {ev.moTa}</div>}
</div>
)}
</dl>
)
}
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
// c. Giá chào thầu = sum quotes của NCC được chọn (winner)
const winnerSupplierRowId = ev.selectedSupplierId
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
: null
const giaChaoThau = winnerSupplierRowId
? ev.details
.flatMap(d => d.quotes)
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
.reduce((sum, q) => sum + q.thanhTien, 0)
: null
// d. Bản so sánh — attachments với purpose=ComparisonTable hoặc supplier-row null
const banSoSanhAttachments = ev.attachments.filter(
a => a.purchaseEvaluationSupplierId === null,
)
return ( return (
<div className="space-y-4"> <div className="space-y-3">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm"> <FormRow
<Field label="Tên gói thầu" value={ev.tenGoiThau} /> label="a. NCC / TP được chọn"
<Field label="Dự án" value={ev.projectName} /> value={ev.selectedSupplierName ? (
<Field label="Địa điểm" value={ev.diaDiem ?? '—'} /> <span className="font-medium text-emerald-700"> {ev.selectedSupplierName}</span>
<Field label="Mô tả" value={ev.moTa ?? '—'} /> ) : <span className="text-slate-400"> (chưa chọn)</span>}
<Field label="NCC được chọn" value={ev.selectedSupplierName ?? '—'} /> />
<Field label="Điều khoản thanh toán" value={ev.paymentTerms ?? '—'} /> <FormRow
<Field label="b. Ngân sách"
label="Ngân sách"
value={ev.budget ? ( value={ev.budget ? (
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline"> <a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span> <span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
{' · '} {' · '}{ev.budget.tenNganSach}
{ev.budget.tenNganSach} {' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
{' · '}
<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a> </a>
) : <span className="text-slate-400"> (chưa link)</span>} ) : <span className="text-slate-400"> (chưa link)</span>}
/> />
{ev.contractId && ( <FormRow
<Field label="HĐ kế thừa" value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>} /> label="c. Giá chào thầu"
value={giaChaoThau != null ? (
<span className="font-semibold text-slate-900">{giaChaoThau.toLocaleString('vi-VN')} đ</span>
) : <span className="text-slate-400"> (chưa chọn NCC / chưa nhập báo giá)</span>}
/>
<div>
<div className="flex gap-3">
<span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bản so sánh</span>
<div className="min-w-0 flex-1">
<GeneralAttachmentsSection
evaluationId={ev.id}
attachments={banSoSanhAttachments}
readOnly={readOnly}
/>
</div>
</div>
</div>
{ev.paymentTerms && (
<FormRow label="Điều khoản thanh toán" value={<span className="whitespace-pre-wrap">{ev.paymentTerms}</span>} />
)} )}
</dl> {ev.contractId && (
<FormRow
label="HĐ kế thừa"
value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>}
/>
)}
{canCreateContract && ( {canCreateContract && (
<div className="rounded border border-emerald-200 bg-emerald-50 p-3"> <div className="rounded border border-emerald-200 bg-emerald-50 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@ -191,6 +243,16 @@ function InfoTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: bool
) )
} }
// Form row: label cố định 176px (w-44) bên trái + value bên phải (giống spec).
function FormRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
<dt className="w-44 shrink-0 text-[12px] text-slate-500">{label}</dt>
<dd className="min-w-0 flex-1 text-slate-800">{value}</dd>
</div>
)
}
function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) { function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) {
const navigate = useNavigate() const navigate = useNavigate()
const [form, setForm] = useState({ const [form, setForm] = useState({
@ -253,15 +315,6 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
) )
} }
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] uppercase tracking-wide text-slate-400">{label}</dt>
<dd className="mt-0.5 text-slate-800">{value}</dd>
</div>
)
}
// ===== Tab: NCC ===== // ===== Tab: NCC =====
function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient() const qc = useQueryClient()