Files
solution-erp/fe-user/src/components/pe/PeDetailTabs.tsx
pqhuy1987 27b291ccea [CLAUDE] FE-User: PE InfoTab inline edit + PeListPanel pencil edit hover mirror
Chunk 2/3 — mirror y hệt Chunk 1 sang fe-user (rule §3.9). 3 file:
  ~ components/pe/PeDetailTabs.tsx — InfoTab inline edit + autoEditHeader prop
  ~ components/pe/PeListPanel.tsx — pencil icon group-hover absolute right
  ~ pages/pe/PurchaseEvaluationWorkspacePage.tsx — URL editHeader=1 wiring

Verify: npm run build fe-user pass · 0 TS error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:57:24 +07:00

1570 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Detail content cho 1 phiếu Duyệt NCC. Flat render (no tabs): Thông tin +
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
// → PeApprovalsSection + PeHistorySection).
import { 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 { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
PeAttachmentPurpose,
PeAttachmentPurposeLabel,
PeDepartmentKind,
PeDepartmentKindLabel,
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
type PeAttachment,
type PeChangelog,
type PeDepartmentOpinion,
type PeDetailBundle,
type PeDetailRow,
type PeQuote,
type PeSupplier,
} from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import type { Paged, Supplier } from '@/types/master'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
// Main detail content — flat render 3 section không tabs.
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
//
// `mode` (2026-05-07):
// - 'detail' (default): full UX — Section 5 Ý kiến 4PB editable theo readOnly.
// Dùng ở leaf "Danh sách" + "Duyệt" (3-panel pages).
// - 'workspace': dùng ở leaf "Thao tác" (2-panel workspace). Section 5 LUÔN
// disabled (Q5 user — ý kiến nhập khi duyệt, không phải workspace nhập liệu).
// Workflow Panel + Approvals + History KHÔNG render trong PeDetailTabs (luôn
// ở caller PeWorkflowPanel — workspace caller skip render Panel 3 hoàn toàn).
export function PeDetailTabs({
evaluation,
onBack,
onDelete,
readOnly = false,
mode = 'detail',
autoEditHeader = false,
}: {
evaluation: PeDetailBundle
onBack: () => void
onDelete: () => void
/** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
readOnly?: boolean
/** 'workspace' = Section 5 LUÔN disabled (ý kiến nhập ở leaf Duyệt). */
mode?: 'detail' | 'workspace'
/** Auto open Section 1 InfoTab in edit mode khi mount — triggered từ pencil icon Panel 1 */
autoEditHeader?: boolean
}) {
const navigate = useNavigate()
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
const opinionsReadOnly = readOnly || mode === 'workspace'
return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-slate-900">{evaluation.tenGoiThau}</h2>
<span
className={cn(
'rounded px-1.5 py-0.5 text-[11px] font-medium',
PurchaseEvaluationPhaseColor[evaluation.phase],
)}
>
{PurchaseEvaluationPhaseLabel[evaluation.phase]}
</span>
{readOnly && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
chế đ duyệt
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
<span className="font-mono">{evaluation.maPhieu ?? '—'}</span>
<span>·</span>
<span>{PurchaseEvaluationTypeLabel[evaluation.type]}</span>
<span>·</span>
<span>{evaluation.projectName}</span>
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
</div>
</div>
<div className="flex gap-2">
{isDraft && !readOnly && (
<>
<Button variant="ghost" onClick={() => navigate(`/purchase-evaluations/new?id=${evaluation.id}`)} className="gap-1.5 text-xs">
<Pencil className="h-3.5 w-3.5" /> Sửa header
</Button>
<Button variant="danger" onClick={onDelete} className="gap-1.5 text-xs">
<Trash2 className="h-3.5 w-3.5" /> Xóa
</Button>
</>
)}
<Button variant="ghost" onClick={onBack} className="text-xs"> Đóng</Button>
</div>
</div>
<div className="divide-y divide-slate-200">
{/* Section 1 — đúng spec form FO-PHIẾU TRÌNH KÝ CHỌN TP/NCC */}
<Section title="1. Thông tin gói thầu">
<InfoTab ev={evaluation} readOnly={readOnly} autoEdit={autoEditHeader} />
</Section>
<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} />
</Section>
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
<ItemsTab ev={evaluation} readOnly={readOnly} />
</Section>
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
{mode === 'workspace' && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
Ý kiến + chữ nhập khi duyệt phiếu vào menu &ldquo;Duyệt&rdquo; đ .
</div>
)}
<DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />
</Section>
</div>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="px-5 py-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</h3>
{children}
</section>
)
}
// ===== Section 5 — Ý kiến 4 phòng ban =====
// Render 2x2 grid 4 box (Phê duyệt / CCM / MuaHàng / SM-PM). Mỗi box hiển
// thị Opinion text + chữ ký (UserName + SignedAt) nếu đã ký, hoặc form nhập
// + 2 button "Lưu" + "Lưu & Ký" khi chưa ký / readOnly=false.
function DepartmentOpinionsSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const KINDS: { kind: number; label: string }[] = [
{ kind: PeDepartmentKind.PheDuyet, label: PeDepartmentKindLabel[PeDepartmentKind.PheDuyet] },
{ kind: PeDepartmentKind.Ccm, label: PeDepartmentKindLabel[PeDepartmentKind.Ccm] },
{ kind: PeDepartmentKind.MuaHang, label: PeDepartmentKindLabel[PeDepartmentKind.MuaHang] },
{ kind: PeDepartmentKind.SmPm, label: PeDepartmentKindLabel[PeDepartmentKind.SmPm] },
]
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{KINDS.map(k => {
const existing = ev.departmentOpinions.find(o => o.kind === k.kind) ?? null
return (
<OpinionBox
key={k.kind}
evaluationId={ev.id}
kind={k.kind}
kindLabel={k.label}
existing={existing}
readOnly={readOnly}
/>
)
})}
</div>
)
}
function OpinionBox({
evaluationId,
kind,
kindLabel,
existing,
readOnly,
}: {
evaluationId: string
kind: number
kindLabel: string
existing: PeDepartmentOpinion | null
readOnly: boolean
}) {
const qc = useQueryClient()
const [text, setText] = useState(existing?.opinion ?? '')
const isSigned = !!existing?.signedAt
const save = useMutation({
mutationFn: async (sign: boolean) =>
api.post(`/purchase-evaluations/${evaluationId}/opinions`, {
kind,
opinion: text || null,
sign,
}),
onSuccess: () => {
toast.success('Đã lưu ý kiến.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div className={cn(
'rounded-lg border bg-white p-3',
isSigned ? 'border-emerald-200' : 'border-slate-200',
)}>
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[13px] font-semibold uppercase tracking-wide text-slate-700">{kindLabel}</h4>
{isSigned && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<Check className="h-3 w-3" /> Đã
</span>
)}
</div>
{readOnly ? (
<>
<div className="min-h-[60px] whitespace-pre-wrap text-sm text-slate-800">
{existing?.opinion ?? <span className="italic text-slate-400"> chưa ý kiến</span>}
</div>
{isSigned && (
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
bởi <strong>{existing?.userName ?? '—'}</strong> · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}
</div>
)}
</>
) : (
<>
<textarea
rows={3}
value={text}
onChange={e => setText(e.target.value)}
placeholder="Nhập ý kiến…"
className="w-full resize-none rounded border border-slate-200 px-2 py-1.5 text-sm focus:border-brand-300 focus:outline-none focus:ring-1 focus:ring-brand-200"
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="text-[11px] text-slate-500">
{isSigned
? <> bởi <strong>{existing?.userName ?? '—'}</strong> · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}</>
: 'Chưa ký'}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
onClick={() => save.mutate(false)}
disabled={save.isPending}
className="text-xs"
>
Lưu text
</Button>
<Button
onClick={() => save.mutate(true)}
disabled={save.isPending}
className="text-xs"
>
{isSigned ? 'Cập nhật chữ ký' : 'Lưu & Ký'}
</Button>
</div>
</div>
</>
)}
</div>
)
}
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử duyệt ({ev.approvals.length})</h3>
<ApprovalsTab ev={ev} />
</div>
)
}
export function PeHistorySection({ ev }: { ev: PeDetailBundle }) {
return (
<div>
<h3 className="mb-2 text-sm font-semibold text-slate-900">Lịch sử thay đi</h3>
<HistoryTab ev={ev} />
</div>
)
}
// ===== Section 1 — Thông tin gói thầu (spec: a. Tên gói thầu / b. Dự án) =====
// Inline editable khi canEdit (=!readOnly && isDraft). Edit pencil button "Sửa"
// flip display ↔ form mode. Save dùng existing PUT /pe/:id endpoint với current
// entity values + new header fields. Dự án + Type LOCKED sau create — chỉ Tên/
// Địa điểm/Mô tả/Payment editable inline. autoEdit prop cho phép trigger edit
// mode từ pencil icon trong PeListPanel (URL flag ?editHeader=1).
function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boolean; autoEdit: boolean }) {
const isDraft = ev.phase === PurchaseEvaluationPhase.DangSoanThao
const canEdit = !readOnly && isDraft
const qc = useQueryClient()
const [editing, setEditing] = useState(autoEdit && canEdit)
const [tenGoiThau, setTenGoiThau] = useState(ev.tenGoiThau)
const [diaDiem, setDiaDiem] = useState(ev.diaDiem ?? '')
const [moTa, setMoTa] = useState(ev.moTa ?? '')
const [paymentTerms, setPaymentTerms] = useState(ev.paymentTerms ?? '')
const dirty = tenGoiThau !== ev.tenGoiThau
|| diaDiem !== (ev.diaDiem ?? '')
|| moTa !== (ev.moTa ?? '')
|| paymentTerms !== (ev.paymentTerms ?? '')
const save = useMutation({
mutationFn: async () => {
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau,
diaDiem: diaDiem || null,
moTa: moTa || null,
paymentTerms: paymentTerms || null,
budgetId: ev.budgetId,
budgetManualName: ev.budgetManualName,
budgetManualAmount: ev.budgetManualAmount,
})
},
onSuccess: () => {
toast.success('Đã cập nhật thông tin')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setEditing(false)
},
onError: e => toast.error(getErrorMessage(e)),
})
function reset() {
setTenGoiThau(ev.tenGoiThau)
setDiaDiem(ev.diaDiem ?? '')
setMoTa(ev.moTa ?? '')
setPaymentTerms(ev.paymentTerms ?? '')
}
if (!editing) {
return (
<dl className="space-y-2 text-sm">
<div className="flex items-start justify-between">
<FormRow label="a. Tên gói thầu" value={ev.tenGoiThau} />
{canEdit && (
<button
onClick={() => setEditing(true)}
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-slate-500 hover:bg-slate-100 hover:text-brand-600"
title="Sửa thông tin gói thầu"
>
<Pencil className="h-3 w-3" /> Sửa
</button>
)}
</div>
<FormRow label="b. Dự án" value={ev.projectName} />
{(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
<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>}
{ev.paymentTerms && <div><span className="text-slate-400">Điều khoản TT:</span> <span className="whitespace-pre-wrap">{ev.paymentTerms}</span></div>}
</div>
)}
</dl>
)
}
// Editing mode
return (
<div className="space-y-3 rounded border border-brand-200 bg-brand-50/30 p-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="md:col-span-2">
<Label className="text-[11px]">a. Tên gói thầu *</Label>
<Input
value={tenGoiThau}
onChange={e => setTenGoiThau(e.target.value)}
placeholder="vd Cung cấp bê tông"
/>
</div>
<div className="md:col-span-2">
<Label className="text-[11px]">b. Dự án (khóa)</Label>
<Input value={ev.projectName} disabled className="bg-slate-100" />
</div>
<div>
<Label className="text-[11px]">Đa điểm</Label>
<Input
value={diaDiem}
onChange={e => setDiaDiem(e.target.value)}
placeholder="Lô K, KCN Lộc An..."
/>
</div>
<div>
<Label className="text-[11px]"> tả ngắn</Label>
<Input
value={moTa}
onChange={e => setMoTa(e.target.value)}
placeholder="Phương án A: ..."
/>
</div>
<div className="md:col-span-2">
<Label className="text-[11px]">Điều khoản thanh toán</Label>
<Input
value={paymentTerms}
onChange={e => setPaymentTerms(e.target.value)}
placeholder="JSON hoặc text"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
onClick={() => { reset(); setEditing(false) }}
className="h-7 px-3 text-xs"
>
Hủy
</Button>
<Button
onClick={() => save.mutate()}
disabled={!dirty || !tenGoiThau || save.isPending}
className="h-7 px-3 text-xs"
>
{save.isPending ? 'Đang lưu…' : 'Lưu'}
</Button>
</div>
</div>
)
}
// ===== b. Ngân sách inline editor (Mig 17) =====
// Hiển thị + edit budget link / manual fields ngay trong Section 2 — KHÔNG cần
// đi tới "Sửa header" page. Visible trong cả 3 view (Workspace / Danh sách /
// Duyệt). Edit chỉ enable khi !readOnly + isDraft (Drafter sửa). Read-only
// khi pendingMe=1 hoặc phase đã chuyển khỏi DangSoanThao. Empty values hiển
// thị empty (per user 2026-05-07).
function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const isDraft = ev.phase === PurchaseEvaluationPhase.DangSoanThao
const canEdit = !readOnly && isDraft
const qc = useQueryClient()
// Detect mode khi mount/refresh: prefer manual mode nếu đã có data manual + ko link
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
const [manualMode, setManualMode] = useState(initialManual)
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
const [manualName, setManualName] = useState(ev.budgetManualName ?? '')
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
// Eligible budgets — chỉ fetch khi user có khả năng edit
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', ev.projectId],
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
})).data.items,
enabled: canEdit,
})
// Dirty detect — compare current state vs ev original
const dirty = manualMode !== initialManual
|| (manualMode && (manualName !== (ev.budgetManualName ?? '') || manualAmount !== (ev.budgetManualAmount ?? 0)))
|| (!manualMode && budgetId !== (ev.budgetId ?? ''))
const save = useMutation({
mutationFn: async () => {
const payload = manualMode
? { budgetId: null, budgetManualName: manualName || null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau: ev.tenGoiThau,
diaDiem: ev.diaDiem,
moTa: ev.moTa,
paymentTerms: ev.paymentTerms,
...payload,
})
},
onSuccess: () => {
toast.success('Đã cập nhật ngân sách')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
// Read-only mode: chỉ display (không toggle, không edit)
if (!canEdit) {
return (
<FormRow
label="b. Ngân sách"
value={ev.budget ? (
<a href={`/budgets?id=${ev.budget.id}`} className="text-brand-600 hover:underline">
<span className="font-mono text-[11px]">{ev.budget.maNganSach ?? '—'}</span>
{' · '}{ev.budget.tenNganSach}
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a>
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
<span className="text-slate-700">
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
{ev.budgetManualAmount != null && (
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
)}
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
</span>
) : <span className="text-slate-400"></span>}
/>
)
}
// Editable mode (canEdit=true)
return (
<div className="flex gap-3">
<span className="w-44 shrink-0 pt-1.5 text-[12px] text-slate-500">b. Ngân sách</span>
<div className="min-w-0 flex-1 space-y-2">
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={manualMode}
onChange={e => setManualMode(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
Nhập tay (không link)
</label>
{!manualMode ? (
<Select
value={budgetId}
onChange={e => setBudgetId(e.target.value)}
className="text-sm"
>
<option value=""></option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<Input
value={manualName}
onChange={e => setManualName(e.target.value)}
placeholder="Tên ngân sách (vd Tạm tính T11/2025)"
maxLength={200}
className="text-sm"
/>
<Input
type="number"
min={0}
value={manualAmount || ''}
onChange={e => setManualAmount(Number(e.target.value))}
placeholder="Số tiền (đ)"
className="text-sm"
/>
</div>
)}
{dirty && (
<div className="flex items-center gap-2">
<Button
onClick={() => save.mutate()}
disabled={save.isPending}
className="h-7 px-3 text-xs"
>
{save.isPending ? 'Đang lưu…' : 'Lưu ngân sách'}
</Button>
<button
onClick={() => {
setManualMode(initialManual)
setBudgetId(ev.budgetId ?? '')
setManualName(ev.budgetManualName ?? '')
setManualAmount(ev.budgetManualAmount ?? 0)
}}
className="text-[11px] text-slate-500 hover:text-slate-700"
>
Hủy thay đi
</button>
</div>
)}
</div>
</div>
)
}
// ===== 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 [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 (
<div className="space-y-3">
<FormRow
label="a. NCC / TP được chọn"
value={ev.selectedSupplierName ? (
<span className="font-medium text-emerald-700"> {ev.selectedSupplierName}</span>
) : <span className="text-slate-400"> (chưa chọn)</span>}
/>
<BudgetFieldRow ev={ev} readOnly={readOnly} />
<FormRow
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>} />
)}
{ev.contractId && (
<FormRow
label="HĐ kế thừa"
value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>}
/>
)}
{canCreateContract && (
<div className="rounded border border-emerald-200 bg-emerald-50 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-emerald-800">
Phiếu đã duyệt. Bấm đ tạo mới kế thừa NCC + hạng mục.
</div>
<Button onClick={() => setCreateOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Tạo từ phiếu
</Button>
</div>
</div>
)}
{createOpen && <CreateContractDialog evaluation={ev} onClose={() => setCreateOpen(false)} />}
</div>
)
}
// 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 }) {
const navigate = useNavigate()
const [form, setForm] = useState({
contractType: 1,
tenHopDong: evaluation.tenGoiThau,
bypassProcurementAndCCM: false,
})
const mut = useMutation({
mutationFn: async () =>
api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form),
onSuccess: res => {
toast.success('Đã tạo HĐ từ phiếu.')
navigate(`/contracts/${res.data.contractId}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
const typeOptions = [
[1, 'HĐ Thầu phụ'],
[2, 'HĐ Giao khoán'],
[3, 'HĐ Nhà cung cấp'],
[4, 'HĐ Dịch vụ'],
[5, 'HĐ Mua bán'],
[6, 'HĐ Nguyên tắc NCC'],
[7, 'HĐ Nguyên tắc DV'],
] as const
return (
<Dialog
open
onClose={onClose}
title="Tạo HĐ từ phiếu Duyệt NCC"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Tạo</Button>
</>}
>
<div className="space-y-3">
<p className="text-sm text-slate-500">
NCC: <strong>{evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
</p>
<div>
<Label>Loại </Label>
<Select value={form.contractType} onChange={e => setForm({ ...form, contractType: Number(e.target.value) })}>
{typeOptions.map(([v, lbl]) => <option key={v} value={v}>{lbl}</option>)}
</Select>
</div>
<div>
<Label>Tên </Label>
<Input value={form.tenHopDong} onChange={e => setForm({ ...form, tenHopDong: e.target.value })} />
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.bypassProcurementAndCCM}
onChange={e => setForm({ ...form, bypassProcurementAndCCM: e.target.checked })}
/>
Bypass CCM (áp dụng với Chủ đu )
</label>
</div>
</Dialog>
)
}
// ===== Tab: NCC =====
function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient()
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 && (
<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]',
ev.selectedSupplierId === s.supplierId
? 'bg-emerald-100 text-emerald-700'
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
)}
title="Chọn NCC thắng"
>
<Check className="h-3.5 w-3.5" />
</button>
<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>
<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>
</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 }) {
const qc = useQueryClient()
const suppliers = useQuery({
queryKey: ['all-suppliers'],
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000 } })).data.items,
})
const [form, setForm] = useState({
supplierId: '',
displayName: '',
contactName: '',
contactEmail: '',
contactPhone: '',
paymentTermText: '',
note: '',
})
const mut = useMutation({
mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form),
onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title="Thêm NCC vào phiếu"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={!form.supplierId || mut.isPending}>Thêm</Button>
</>}
>
<div className="space-y-3">
<div>
<Label>NCC (master)</Label>
<Select value={form.supplierId} onChange={e => setForm({ ...form, supplierId: e.target.value })}>
<option value="">-- Chọn --</option>
{suppliers.data?.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" /></div>
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} placeholder="vd 30 ngày, 300tr" /></div>
<div><Label>Người liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
<div><Label>Điện thoại</Label><Input value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} /></div>
<div className="col-span-2"><Label>Email</Label><Input value={form.contactEmail} onChange={e => setForm({ ...form, contactEmail: e.target.value })} /></div>
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." /></div>
</div>
</div>
</Dialog>
)
}
function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeSupplier; onClose: () => void }) {
const qc = useQueryClient()
const [form, setForm] = useState({
supplierId: row.supplierId,
displayName: row.displayName ?? '',
contactName: row.contactName ?? '',
contactEmail: row.contactEmail ?? '',
contactPhone: row.contactPhone ?? '',
paymentTermText: row.paymentTermText ?? '',
note: row.note ?? '',
})
const mut = useMutation({
mutationFn: async () => api.put(`/purchase-evaluations/${evaluationId}/suppliers/${row.id}`, form),
onSuccess: () => { toast.success('Đã cập nhật.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title={`Sửa NCC — ${row.supplierName}`}
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
</>}
>
<div className="grid grid-cols-2 gap-3">
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} /></div>
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} /></div>
<div><Label>Liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
<div><Label>Điện thoại</Label><Input value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} /></div>
<div className="col-span-2"><Label>Email</Label><Input value={form.contactEmail} onChange={e => setForm({ ...form, contactEmail: e.target.value })} /></div>
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
</div>
</Dialog>
)
}
// ===== Tab: Hạng mục + Báo giá (matrix) =====
function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient()
const [addOpen, setAddOpen] = useState(false)
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.
// 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 }>(
`/budgets/${ev.budgetId}`)).data,
enabled: !!ev.budgetId,
})
const budgetRowMap = (() => {
const m = new Map<string, number>()
budgetBundle.data?.details.forEach(d => {
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
})
return m
})()
const showBudgetCol = !!ev.budgetId
const totalPeNganSach = ev.details.reduce((sum, d) => sum + d.thanhTienNganSach, 0)
const totalBudget = budgetBundle.data?.tongNganSach ?? 0
return (
<div>
<div className="mb-3 flex items-center justify-between">
<p className="text-xs text-slate-500">
{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á.`}
</p>
{!readOnly && (
<Button onClick={() => setAddOpen(true)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Thêm hạng mục
</Button>
)}
</div>
{ev.details.length === 0 ? (
<p className="text-sm text-slate-500">Chưa hạng mục.</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="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-left">Hạng mục</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">KL</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">ĐG ngân sách</th>
<th className="border-r border-slate-200 px-2 py-2 text-right">TT ngân sách</th>
{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">
NS link · Δ
</th>
)}
{ev.suppliers.map(s => (
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right">
{s.displayName ?? 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)
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',
q?.isSelected && '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>
)}
{addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />}
{editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />}
{quoteEdit && (
<QuoteDialog
evaluationId={ev.id}
detailId={quoteEdit.detail.id}
supplierRowId={quoteEdit.supplier.id}
supplierName={quoteEdit.supplier.supplierName}
itemName={quoteEdit.detail.noiDung}
khoiLuong={quoteEdit.detail.khoiLuongThiCong || quoteEdit.detail.khoiLuongNganSach}
existing={quoteEdit.existing}
onClose={() => setQuoteEdit(null)}
/>
)}
</div>
)
}
function DetailDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeDetailRow | null; onClose: () => void }) {
const qc = useQueryClient()
const [form, setForm] = useState({
groupCode: row?.groupCode ?? 'A.I',
groupName: row?.groupName ?? '',
itemCode: row?.itemCode ?? '',
noiDung: row?.noiDung ?? '',
donViTinh: row?.donViTinh ?? '',
khoiLuongNganSach: row?.khoiLuongNganSach ?? 0,
khoiLuongThiCong: row?.khoiLuongThiCong ?? 0,
donGiaNganSach: row?.donGiaNganSach ?? 0,
thanhTienNganSach: row?.thanhTienNganSach ?? 0,
ghiChu: row?.ghiChu ?? '',
})
const mut = useMutation({
mutationFn: async () =>
row
? api.put(`/purchase-evaluations/${evaluationId}/details/${row.id}`, form)
: api.post(`/purchase-evaluations/${evaluationId}/details`, form),
onSuccess: () => { toast.success(row ? 'Đã sửa.' : 'Đã thêm.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const updateAndRecalc = (patch: Partial<typeof form>) => {
const next = { ...form, ...patch }
// Auto-compute ThanhTien = KL ngân sách × ĐG ngân sách
next.thanhTienNganSach = Number(next.khoiLuongNganSach) * Number(next.donGiaNganSach)
setForm(next)
}
return (
<Dialog
open
onClose={onClose}
title={(row ? 'Sửa' : 'Thêm') + ' hạng mục'}
size="lg"
footer={<>
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>{row ? 'Lưu' : 'Thêm'}</Button>
</>}
>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div><Label>Nhóm (A.I/A.II...)</Label><Input value={form.groupCode} onChange={e => setForm({ ...form, groupCode: e.target.value })} /></div>
<div className="col-span-2"><Label>Tên nhóm</Label><Input value={form.groupName} onChange={e => setForm({ ...form, groupName: e.target.value })} placeholder="Bê tông / Phụ gia..." /></div>
<div><Label> (tùy chọn)</Label><Input value={form.itemCode} onChange={e => setForm({ ...form, itemCode: e.target.value })} /></div>
<div className="col-span-2"><Label>Nội dung</Label><Input value={form.noiDung} onChange={e => setForm({ ...form, noiDung: e.target.value })} /></div>
<div><Label>ĐVT</Label><Input value={form.donViTinh} onChange={e => setForm({ ...form, donViTinh: e.target.value })} /></div>
<div><Label>KL ngân sách</Label><Input type="number" value={form.khoiLuongNganSach} onChange={e => updateAndRecalc({ khoiLuongNganSach: Number(e.target.value) })} /></div>
<div><Label>KL thi công</Label><Input type="number" value={form.khoiLuongThiCong} onChange={e => setForm({ ...form, khoiLuongThiCong: Number(e.target.value) })} /></div>
<div><Label>Đơn giá ngân sách</Label><Input type="number" value={form.donGiaNganSach} onChange={e => updateAndRecalc({ donGiaNganSach: Number(e.target.value) })} /></div>
<div className="col-span-2"><Label>Thành tiền ngân sách (auto)</Label><Input type="number" value={form.thanhTienNganSach} onChange={e => setForm({ ...form, thanhTienNganSach: Number(e.target.value) })} /></div>
<div className="col-span-3"><Label>Ghi chú</Label><Input value={form.ghiChu} onChange={e => setForm({ ...form, ghiChu: e.target.value })} /></div>
</div>
</div>
</Dialog>
)
}
function QuoteDialog({
evaluationId, detailId, supplierRowId, supplierName, itemName, khoiLuong, existing, onClose,
}: {
evaluationId: string
detailId: string
supplierRowId: string
supplierName: string
itemName: string
khoiLuong: number
existing: PeQuote | null
onClose: () => void
}) {
const qc = useQueryClient()
const [form, setForm] = useState({
bgVat: existing?.bgVat ?? 0,
chuaVat: existing?.chuaVat ?? 0,
thanhTien: existing?.thanhTien ?? 0,
isSelected: existing?.isSelected ?? false,
note: existing?.note ?? '',
})
const updateAndRecalc = (patch: Partial<typeof form>) => {
const next = { ...form, ...patch }
next.thanhTien = Number(next.chuaVat) * khoiLuong
setForm(next)
}
const mut = useMutation({
mutationFn: async () =>
api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
purchaseEvaluationSupplierId: supplierRowId,
...form,
}),
onSuccess: () => { toast.success('Đã lưu báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async () =>
existing ? api.delete(`/purchase-evaluations/${evaluationId}/quotes/${existing.id}`) : Promise.resolve(),
onSuccess: () => { toast.success('Đã xóa báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
return (
<Dialog
open
onClose={onClose}
title={`Báo giá — ${supplierName}`}
footer={<>
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={del.isPending}>Xóa</Button>}
<Button variant="ghost" onClick={onClose}>Hủy</Button>
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
</>}
>
<div className="space-y-3">
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong> · KL {khoiLuong}</p>
<div className="grid grid-cols-3 gap-3">
<div><Label>Đơn giá chưa VAT</Label><Input type="number" value={form.chuaVat} onChange={e => updateAndRecalc({ chuaVat: Number(e.target.value) })} /></div>
<div><Label>Đơn giá VAT</Label><Input type="number" value={form.bgVat} onChange={e => setForm({ ...form, bgVat: Number(e.target.value) })} /></div>
<div><Label>Thành tiền (auto)</Label><Input type="number" value={form.thanhTien} onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })} /></div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isSelected} onChange={e => setForm({ ...form, isSelected: e.target.checked })} />
Chọn NCC này cho hạng mục
</label>
<div><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
</div>
</Dialog>
)
}
// ===== Tab: Duyệt =====
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
if (ev.approvals.length === 0) return <p className="text-sm text-slate-500">Chưa bước duyệt nào.</p>
return (
<ol className="space-y-2">
{ev.approvals.map(a => (
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.fromPhase])}>
{PurchaseEvaluationPhaseLabel[a.fromPhase]}
</span>
<span className="mx-2"></span>
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.toPhase])}>
{PurchaseEvaluationPhaseLabel[a.toPhase]}
</span>
</div>
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
</div>
<div className="mt-1 text-xs text-slate-500">
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
</div>
</li>
))}
</ol>
)
}
// ===== Tab: Lịch sử =====
function HistoryTab({ ev }: { ev: PeDetailBundle }) {
const logs = useQuery({
queryKey: ['pe-changelog', ev.id],
queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data,
})
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải</p>
if (!logs.data || logs.data.length === 0) return <p className="text-sm text-slate-500">Chưa lịch sử.</p>
return (
<ol className="space-y-1.5 text-sm">
{logs.data.map(l => (
<li key={l.id} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{l.userName ?? 'Hệ thống'}</span>
<span>{new Date(l.createdAt).toLocaleString('vi-VN')}</span>
</div>
<div className="text-slate-800">{l.summary}</div>
{l.contextNote && <div className="text-xs text-slate-500">{l.contextNote}</div>}
</li>
))}
</ol>
)
}
// ===== Cell upload file đính kèm per-NCC =====
// 1 row = 1 NCC. User upload file báo giá (purpose=QuoteDocument mặc định) →
// POST multipart với supplierRowId. List N file hiện có + Download/Delete inline.
// Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
function SupplierAttachmentsCell({
evaluationId,
supplierRowId,
attachments,
readOnly = false,
}: {
evaluationId: string
supplierRowId: string
attachments: PeAttachment[]
readOnly?: boolean
}) {
const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
fd.append('supplierRowId', supplierRowId)
fd.append('purpose', String(PeAttachmentPurpose.QuoteDocument))
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => {
toast.success('Đã tải lên.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async (attId: string) =>
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
onSuccess: () => {
toast.success('Đã xóa.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
async function download(att: PeAttachment) {
try {
const res = await api.get(
`/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
)
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
toast.error(getErrorMessage(e))
}
}
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
if (f) upload.mutate(f)
e.target.value = ''
}
const fmtSize = (b: number) =>
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
return (
<div className="space-y-1">
{attachments.length === 0 && (
<div className="text-[11px] italic text-slate-400">Chưa file</div>
)}
{attachments.map(a => (
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<button
onClick={() => download(a)}
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
title={a.fileName}
>
{a.fileName}
</button>
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(a.fileSize)}</span>
<span className="shrink-0 rounded bg-slate-200 px-1 text-[9px] text-slate-600">
{PeAttachmentPurposeLabel[a.purpose] ?? ''}
</span>
{!readOnly && (
<button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
))}
{!readOnly && (
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
onChange={onPick}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={upload.isPending}
className="inline-flex items-center gap-1 rounded border border-dashed border-slate-300 px-2 py-0.5 text-[11px] text-slate-500 hover:border-brand-300 hover:text-brand-700 disabled:opacity-50"
>
<Upload className="h-3 w-3" />
{upload.isPending ? 'Đang tải…' : '+ Thêm file'}
</button>
</div>
)}
</div>
)
}
// ===== Section Bảng so sánh — general attachments (không gắn NCC cụ thể) =====
// Purpose mặc định = ComparisonTable (4). Upload file Excel/PDF tổng hợp so
// sánh giá N NCC × M hạng mục. Storage path giống SupplierAttachmentsCell
// nhưng supplierRowId KHÔNG truyền → BE lưu NULL.
function GeneralAttachmentsSection({
evaluationId,
attachments,
readOnly = false,
}: {
evaluationId: string
attachments: PeAttachment[]
readOnly?: boolean
}) {
const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
// KHÔNG append supplierRowId → BE set NULL → general attachment
fd.append('purpose', String(PeAttachmentPurpose.ComparisonTable))
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => {
toast.success('Đã tải lên bảng so sánh.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async (attId: string) =>
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
onSuccess: () => {
toast.success('Đã xóa.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
async function download(att: PeAttachment) {
try {
const res = await api.get(
`/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
)
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
toast.error(getErrorMessage(e))
}
}
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
if (f) upload.mutate(f)
e.target.value = ''
}
const fmtSize = (b: number) =>
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
return (
<div>
{!readOnly && (
<p className="mb-2 text-[12px] text-slate-500">
File Excel/PDF tổng hợp so sánh giá của tất cả NCC (không gắn với 1 NCC cụ thể).
</p>
)}
{attachments.length === 0 && readOnly && (
<p className="text-sm italic text-slate-400">Chưa bảng so sánh.</p>
)}
{attachments.length > 0 && (
<div className="mb-2 space-y-1.5">
{attachments.map(a => (
<div
key={a.id}
className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm"
>
<Paperclip className="h-4 w-4 shrink-0 text-brand-500" />
<button
onClick={() => download(a)}
className="min-w-0 flex-1 truncate text-left font-medium text-slate-800 hover:text-brand-700 hover:underline"
title={a.fileName}
>
{a.fileName}
</button>
<span className="shrink-0 text-[11px] text-slate-500">{fmtSize(a.fileSize)}</span>
<span className="shrink-0 rounded bg-brand-50 px-1.5 py-0.5 text-[10px] text-brand-700">
{PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'}
</span>
<span className="shrink-0 text-[10px] text-slate-400">
{new Date(a.createdAt).toLocaleDateString('vi-VN')}
</span>
{!readOnly && (
<button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded p-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
)}
{!readOnly && (
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
onChange={onPick}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={upload.isPending}
className="inline-flex items-center gap-1.5 rounded border border-dashed border-brand-300 bg-brand-50/50 px-3 py-2 text-xs font-medium text-brand-700 hover:border-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<Upload className="h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải…' : '+ Tải lên bảng so sánh'}
</button>
</div>
)}
</div>
)
}