[CLAUDE] FE-Admin+FE-User: PurchaseEvaluation pages (3-panel list + tabs detail)

Types + pages + components cho module Duyệt NCC ở cả 2 FE (copy-share).

Pages:
 - PurchaseEvaluationsListPage: 3-panel lg:grid-cols-[340px_1fr_360px]
   * Panel 1: list filter theo type/phase/search + pendingMe inbox mode
   * Panel 2: PeDetailTabs (Thông tin/NCC/Hạng mục/Duyệt/Lịch sử)
   * Panel 3: PeWorkflowPanel với timeline + nextPhase buttons
   * Mobile fallback fullpage /purchase-evaluations/:id
 - PurchaseEvaluationCreatePage: form create/edit header (Type / Tên gói thầu
   / Dự án / Địa điểm / Mô tả / PaymentTerms JSON). Suppliers+Details+Quotes
   thêm sau khi save ở Detail tabs.

Components:
 - PeDetailTabs: 5 tab + dialogs (AddSupplier/EditSupplier/DetailDialog/
   QuoteDialog) + matrix N NCC × M hạng mục clickable cells + select winner
 - PeWorkflowPanel: policy timeline từ BE workflow.activePhases + transition
   confirmation dialog với comment

Routes (cả 2 app):
 - /purchase-evaluations (+ ?type=1|2&pendingMe=1&id=...)
 - /purchase-evaluations/new (+ ?type / ?id để edit)
 - /purchase-evaluations/:id (mobile fullpage)

Menu resolver:
 - Pe_<Code>_List → /purchase-evaluations?type=N
 - Pe_<Code>_Create → /purchase-evaluations/new?type=N
 - Pe_<Code>_Pending → /purchase-evaluations?type=N&pendingMe=1
 - PeWf_<Code> (fe-admin only) → /system/pe-workflows/<code>

Skip MVP: PE Workflow admin designer UI, PE Attachments. TS build pass
cả 2 app.
This commit is contained in:
pqhuy1987
2026-04-23 16:56:26 +07:00
parent 4678d192e2
commit a737196b21
16 changed files with 2726 additions and 1 deletions

View File

@ -0,0 +1,624 @@
// Detail tabs cho 1 phiếu Duyệt NCC: Thông tin / NCC / Hạng mục + Báo giá /
// Duyệt / Lịch sử. Inline action dialog để add NCC, add Detail, upsert Quote,
// select winner.
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { Check, Pencil, Plus, Trash2 } 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 {
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
type PeChangelog,
type PeDetailBundle,
type PeDetailRow,
type PeQuote,
type PeSupplier,
} from '@/types/purchaseEvaluation'
import type { Supplier } from '@/types/master'
type TabKey = 'info' | 'suppliers' | 'items' | 'approvals' | 'history'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function PeDetailTabs({
evaluation,
onBack,
onDelete,
}: {
evaluation: PeDetailBundle
onBack: () => void
onDelete: () => void
}) {
const [tab, setTab] = useState<TabKey>('info')
const navigate = useNavigate()
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
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>
</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 && (
<>
<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>
<nav className="flex gap-1 border-b border-slate-200 px-3 pt-2">
{(
[
['info', 'Thông tin'],
['suppliers', `NCC (${evaluation.suppliers.length})`],
['items', `Hạng mục (${evaluation.details.length})`],
['approvals', `Duyệt (${evaluation.approvals.length})`],
['history', 'Lịch sử'],
] as const
).map(([k, lbl]) => (
<button
key={k}
onClick={() => setTab(k)}
className={cn(
'rounded-t-md border-b-2 px-3 py-1.5 text-xs font-medium transition',
tab === k
? 'border-brand-500 text-brand-700'
: 'border-transparent text-slate-500 hover:text-slate-700',
)}
>
{lbl}
</button>
))}
</nav>
<div className="p-5">
{tab === 'info' && <InfoTab ev={evaluation} />}
{tab === 'suppliers' && <SuppliersTab ev={evaluation} />}
{tab === 'items' && <ItemsTab ev={evaluation} />}
{tab === 'approvals' && <ApprovalsTab ev={evaluation} />}
{tab === 'history' && <HistoryTab ev={evaluation} />}
</div>
</div>
)
}
// ===== Tab: Thông tin =====
function InfoTab({ ev }: { ev: PeDetailBundle }) {
return (
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<Field label="Tên gói thầu" value={ev.tenGoiThau} />
<Field label="Dự án" value={ev.projectName} />
<Field label="Địa điểm" value={ev.diaDiem ?? '—'} />
<Field label="Mô tả" value={ev.moTa ?? '—'} />
<Field label="NCC được chọn" value={ev.selectedSupplierName ?? '—'} />
<Field label="Điều khoản thanh toán" value={ev.paymentTerms ?? '—'} />
{ev.contractId && (
<Field label="HĐ kế thừa" value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline"> Xem </a>} />
)}
</dl>
)
}
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 =====
function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
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>
<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">Chưa 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">Hiển thị</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">Ghi chú</th>
<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(ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
<td className="px-3 py-2 font-medium text-slate-900">{s.supplierName}</td>
<td className="px-3 py-2">{s.displayName ?? '—'}</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 text-[12px] text-slate-600">{s.note ?? '—'}</td>
<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 }: { ev: PeDetailBundle }) {
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
return (
<div>
<div className="mb-3 flex items-center justify-between">
<p className="text-xs text-slate-500">
{ev.suppliers.length === 0
? 'Thêm NCC ở tab "NCC" trước khi nhập báo giá.'
: `${ev.details.length} hạng mục × ${ev.suppliers.length} NCC — click ô để nhập báo giá.`}
</p>
<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>
{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>
))}
<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>
{ev.suppliers.map(s => {
const q = quoteKey(d.id, s.id)
return (
<td
key={s.id}
onClick={() => setQuoteEdit({ detail: d, supplier: s, existing: q })}
className={cn(
'cursor-pointer border-r border-slate-200 px-2 py-2 text-right font-mono transition 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>
)
})}
<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>
</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>
)
}