[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

@ -9,6 +9,8 @@ import { InboxPage } from '@/pages/InboxPage'
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
function App() {
return (
@ -28,6 +30,9 @@ function App() {
<Route path="/contracts/new" element={<ContractCreatePage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/my-contracts" element={<MyContractsPage />} />
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="*"

View File

@ -39,8 +39,9 @@ function getCtGroupCode(key: string): string | null {
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
function resolvePath(key: string): string | null {
const staticMap: Record<string, string> = {
Dashboard: '/dashboard', // Tổng quan riêng — KHÔNG trùng /inbox (Hộp thư)
Dashboard: '/dashboard',
Contracts: '/my-contracts',
PurchaseEvaluations: '/purchase-evaluations',
}
if (staticMap[key]) return staticMap[key]
@ -53,6 +54,18 @@ function resolvePath(key: string): string | null {
if (action === 'Create') return `/contracts/new?type=${typeInt}`
if (action === 'Pending') return `/inbox?type=${typeInt}`
}
// Pe_<Code>_<Action> cho module Duyệt NCC (user side)
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending)$/)
if (peMatch) {
const [, code, action] = peMatch
const PE_CODE_TO_INT: Record<string, number> = { DuyetNcc: 1, DuyetNccPhuongAn: 2 }
const typeInt = PE_CODE_TO_INT[code]
if (!typeInt) return null
if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
if (action === 'Create') return `/purchase-evaluations/new?type=${typeInt}`
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
}
return null
}

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>
)
}

View File

@ -0,0 +1,124 @@
// Panel 3: workflow + transition buttons. Pulls nextPhases từ BE bundle
// (single source of truth) → render per-phase action button.
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { Label } from '@/components/ui/Label'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) {
const [target, setTarget] = useState<number | null>(null)
const [comment, setComment] = useState('')
const qc = useQueryClient()
const transition = useMutation({
mutationFn: async () =>
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: target,
decision: target === PurchaseEvaluationPhase.TuChoi ? 2 : 1,
comment: comment || null,
}),
onSuccess: () => {
toast.success('Đã chuyển phase.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setTarget(null)
setComment('')
},
onError: e => toast.error(getErrorMessage(e)),
})
const next = evaluation.workflow.nextPhases
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình</h3>
<p className="mt-0.5 text-[11px] text-slate-500">{evaluation.workflow.policyDescription}</p>
</div>
<ol className="space-y-1.5">
{evaluation.workflow.activePhases
.filter(p => p !== PurchaseEvaluationPhase.TuChoi)
.map(p => {
const isCurrent = evaluation.phase === p
const isPast = isPastPhase(evaluation.phase, p, evaluation.workflow.activePhases)
return (
<li key={p}>
<div
className={cn(
'flex items-center gap-2 rounded border px-2 py-1.5 text-xs',
isCurrent && 'border-brand-300 bg-brand-50 font-medium',
isPast && 'border-emerald-200 bg-emerald-50 text-emerald-700',
!isCurrent && !isPast && 'border-slate-200 text-slate-500',
)}
>
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', PurchaseEvaluationPhaseColor[p])}>
{p}
</span>
<span className="truncate">{PurchaseEvaluationPhaseLabel[p]}</span>
{isCurrent && <span className="ml-auto text-[10px] text-brand-700"> hiện tại</span>}
{isPast && <span className="ml-auto text-[10px] text-emerald-600"></span>}
</div>
</li>
)
})}
</ol>
{next.length > 0 && (
<div>
<Label className="text-xs">Chuyển tiếp:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{next.map(p => (
<button
key={p}
onClick={() => setTarget(p)}
className={cn(
'rounded border px-2 py-1 text-[11px] transition',
p === PurchaseEvaluationPhase.TuChoi
? 'border-red-200 text-red-700 hover:bg-red-50'
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
)}
>
{PurchaseEvaluationPhaseLabel[p]}
</button>
))}
</div>
</div>
)}
{target !== null && (
<Dialog
open
onClose={() => setTarget(null)}
title={`Chuyển → ${PurchaseEvaluationPhaseLabel[target]}`}
footer={<>
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
</>}
>
<Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
</Dialog>
)}
</div>
)
}
function isPastPhase(current: number, p: number, active: number[]): boolean {
const orderedIdx = active.indexOf(p)
const currentIdx = active.indexOf(current)
if (orderedIdx < 0 || currentIdx < 0) return false
return orderedIdx < currentIdx && p !== PurchaseEvaluationPhase.TuChoi
}

View File

@ -12,6 +12,8 @@ export const MenuKeys = {
Users: 'Users',
Roles: 'Roles',
Permissions: 'Permissions',
PurchaseEvaluations: 'PurchaseEvaluations',
PeWorkflows: 'PeWorkflows',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -0,0 +1,176 @@
// Create / edit draft phiếu Duyệt NCC (Header only — Suppliers + Details + Quotes
// chỉnh sửa ở Detail tabs sau khi save).
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
PurchaseEvaluationType,
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
import type { Project } from '@/types/master'
export function PurchaseEvaluationCreatePage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp] = useSearchParams()
const editId = sp.get('id')
const urlType = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
const projects = useQuery({
queryKey: ['all-projects'],
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
})
const existing = useQuery({
queryKey: ['pe-detail', editId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${editId}`)).data,
enabled: !!editId,
})
const [form, setForm] = useState({
type: urlType as number,
tenGoiThau: '',
projectId: '',
diaDiem: '',
moTa: '',
paymentTerms: '',
})
useEffect(() => {
if (existing.data) {
setForm({
type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau,
projectId: existing.data.projectId,
diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '',
})
}
}, [existing.data])
const mut = useMutation({
mutationFn: async () => {
if (editId) {
return api.put(`/purchase-evaluations/${editId}`, {
id: editId,
tenGoiThau: form.tenGoiThau,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
})
}
return api.post<{ id: string }>('/purchase-evaluations', {
type: form.type,
tenGoiThau: form.tenGoiThau,
projectId: form.projectId,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
})
},
onSuccess: res => {
toast.success(editId ? 'Đã lưu.' : 'Đã tạo phiếu.')
qc.invalidateQueries({ queryKey: ['pe-list'] })
const id = editId ?? (res as { data: { id: string } }).data.id
navigate(`/purchase-evaluations?id=${id}&type=${form.type}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
<div className="space-y-4 p-6">
<header className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">
{editId ? 'Sửa phiếu Duyệt NCC' : 'Tạo phiếu Duyệt NCC mới'}
</h1>
</header>
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div>
<Label>Loại quy trình</Label>
<Select
value={form.type}
disabled={!!editId}
onChange={e => setForm({ ...form, type: Number(e.target.value) })}
>
{Object.values(PurchaseEvaluationType).map(t => (
<option key={t} value={t}>{PurchaseEvaluationTypeLabel[t]}</option>
))}
</Select>
</div>
<div>
<Label>Tên gói thầu *</Label>
<Input
value={form.tenGoiThau}
onChange={e => setForm({ ...form, tenGoiThau: e.target.value })}
placeholder="vd Cung cấp bê tông"
/>
</div>
<div>
<Label>Dự án *</Label>
<Select
value={form.projectId}
disabled={!!editId}
onChange={e => setForm({ ...form, projectId: e.target.value })}
>
<option value="">-- Chọn --</option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div>
<div>
<Label>Đa điểm</Label>
<Input
value={form.diaDiem}
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
placeholder="Lô K, KCN Lộc An - Bình Sơn..."
/>
</div>
<div>
<Label> tả</Label>
<Textarea
rows={3}
value={form.moTa}
onChange={e => setForm({ ...form, moTa: e.target.value })}
/>
</div>
<div>
<Label>Điều khoản thanh toán (JSON hoặc text)</Label>
<Textarea
rows={3}
value={form.paymentTerms}
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
placeholder='{"tamUng":"10%","thanhToanTam":"100% W.done","quyetToan":"Final Account","baoHanh":"5%"}'
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
<Button
onClick={() => mut.mutate()}
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
>
{editId ? 'Lưu' : 'Tạo phiếu'}
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,250 @@
// List + Detail phiếu Duyệt NCC — 3-panel: List | Detail tabs | Workflow + history.
// URL params: type (filter A/B), pendingMe (1=inbox), id (selected), q (search).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck, Plus, Search, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/EmptyState'
import { SlaTimer } from '@/components/SlaTimer'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged } from '@/types/master'
import {
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
type PeListItem,
} from '@/types/purchaseEvaluation'
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
export function PurchaseEvaluationsListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const typeFilter = sp.get('type') ? Number(sp.get('type')) : null
const pendingMe = sp.get('pendingMe') === '1'
const search = sp.get('q') ?? ''
const phase = sp.get('phase') ?? ''
const selectedId = sp.get('id')
const list = useQuery({
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
queryFn: async () => {
if (pendingMe) {
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
params: { type: typeFilter ?? undefined },
})
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
}
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations', {
params: {
pageSize: 50,
search: search || undefined,
type: typeFilter ?? undefined,
phase: phase || undefined,
},
})
return res.data
},
})
const detail = useQuery({
queryKey: ['pe-detail', selectedId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => {
toast.success('Đã xóa phiếu.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(sp)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
if (key !== 'id') next.delete('page')
setSp(next, { replace: key === 'q' })
}
function selectRow(id: string) {
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setParam('id', id)
} else {
navigate(`/purchase-evaluations/${id}`)
}
}
const rows = list.data?.items ?? []
const createHref =
typeFilter != null ? `/purchase-evaluations/new?type=${typeFilter}` : '/purchase-evaluations/new'
const headerTitle = typeFilter
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
{list.data?.total ?? 0}
</span>
</div>
<Button onClick={() => navigate(createHref)} className="gap-1.5 text-xs">
<Plus className="h-3.5 w-3.5" /> Tạo phiếu mới
</Button>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_1fr_360px]">
{/* Panel 1: List */}
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
<div className="space-y-2 border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setParam('q', e.target.value)}
placeholder="Tìm mã / tên gói thầu / dự án…"
className="pl-8"
/>
</div>
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
<option value="">Tất cả phase</option>
{Object.values(PurchaseEvaluationPhase).map(p => (
<option key={p} value={p}>{PurchaseEvaluationPhaseLabel[p]}</option>
))}
</Select>
</div>
<div className="flex-1 overflow-y-auto">
{list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!list.isLoading && rows.length === 0 && (
<div className="p-6">
<EmptyState icon={ClipboardCheck} title="Chưa có phiếu" description="Tạo phiếu mới để bắt đầu quy trình." />
</div>
)}
<ul className="divide-y divide-slate-100">
{rows.map(p => (
<li key={p.id}>
<button
onClick={() => selectRow(p.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
<span>·</span>
<span className="truncate">{p.projectName}</span>
</div>
{p.selectedSupplierName && (
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
{p.selectedSupplierName}
</div>
)}
</div>
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
PurchaseEvaluationPhaseColor[p.phase],
)}
>
{PurchaseEvaluationPhaseLabel[p.phase]}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
{PurchaseEvaluationTypeLabel[p.type]}
</span>
<SlaTimer deadline={p.slaDeadline} createdAt={p.createdAt} />
</div>
{p.contractId && (
<div className="mt-1 text-[10px] text-brand-600"> Đã tạo </div>
)}
</button>
</li>
))}
</ul>
</div>
</aside>
{/* Panel 2: Detail tabs */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{selectedId && detail.data && (
<PeDetailTabs
evaluation={detail.data}
onBack={() => setParam('id', null)}
onDelete={() => del.mutate(detail.data!.id)}
/>
)}
</main>
{/* Panel 3: Workflow + history */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
Quy trình duyệt sẽ hiện khi chọn phiếu.
</div>
)}
{selectedId && detail.data && <PeWorkflowPanel evaluation={detail.data} />}
</aside>
</div>
</div>
)
}
// Fullpage detail route cho mobile (/purchase-evaluations/:id)
export function PurchaseEvaluationDetailPage() {
const navigate = useNavigate()
const id = location.pathname.split('/').pop()!
const detail = useQuery({
queryKey: ['pe-detail', id],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${id}`)).data,
})
const del = useMutation({
mutationFn: async () => api.delete(`/purchase-evaluations/${id}`),
onSuccess: () => {
toast.success('Đã xóa.')
navigate('/purchase-evaluations')
},
})
if (detail.isLoading) return <div className="p-6 text-sm text-slate-500">Đang tải</div>
if (!detail.data) return <div className="p-6 text-sm text-red-600">Không tìm thấy phiếu.</div>
return (
<div className="space-y-4 p-6">
<PeDetailTabs evaluation={detail.data} onBack={() => navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
<PeWorkflowPanel evaluation={detail.data} />
</div>
)
}

View File

@ -0,0 +1,165 @@
// Types cho module Duyệt NCC (PurchaseEvaluation) — mirror BE Domain.
export const PurchaseEvaluationType = {
DuyetNcc: 1,
DuyetNccPhuongAn: 2,
} as const
export type PurchaseEvaluationType = typeof PurchaseEvaluationType[keyof typeof PurchaseEvaluationType]
export const PurchaseEvaluationTypeLabel: Record<number, string> = {
1: 'Duyệt NCC',
2: 'Duyệt NCC - Phương án',
}
export const PurchaseEvaluationTypeCode: Record<number, string> = {
1: 'DuyetNcc',
2: 'DuyetNccPhuongAn',
}
export const PurchaseEvaluationPhase = {
DangSoanThao: 1,
ChoPurchasing: 2,
ChoDuAn: 3,
ChoCCM: 4,
ChoCEODuyetPA: 5,
ChoCEODuyetNCC: 6,
DaDuyet: 7,
TuChoi: 99,
} as const
export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase]
export const PurchaseEvaluationPhaseLabel: Record<number, string> = {
1: 'Đang soạn thảo',
2: 'Chờ Purchasing',
3: 'Chờ Dự án',
4: 'Chờ CCM',
5: 'Chờ CEO duyệt PA',
6: 'Chờ CEO duyệt NCC',
7: 'Đã duyệt',
99: 'Từ chối',
}
export const PurchaseEvaluationPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-blue-100 text-blue-700',
3: 'bg-orange-100 text-orange-700',
4: 'bg-indigo-100 text-indigo-700',
5: 'bg-fuchsia-100 text-fuchsia-700',
6: 'bg-pink-100 text-pink-700',
7: 'bg-emerald-100 text-emerald-700',
99: 'bg-red-100 text-red-700',
}
export type PeListItem = {
id: string
maPhieu: string | null
tenGoiThau: string
type: number
phase: number
projectId: string
projectName: string
selectedSupplierId: string | null
selectedSupplierName: string | null
contractId: string | null
slaDeadline: string | null
createdAt: string
}
export type PeSupplier = {
id: string
supplierId: string
supplierName: string
displayName: string | null
contactName: string | null
contactEmail: string | null
contactPhone: string | null
paymentTermText: string | null
note: string | null
order: number
}
export type PeQuote = {
id: string
purchaseEvaluationDetailId: string
purchaseEvaluationSupplierId: string
bgVat: number
chuaVat: number
thanhTien: number
isSelected: boolean
note: string | null
}
export type PeDetailRow = {
id: string
groupCode: string
groupName: string
itemCode: string | null
noiDung: string
donViTinh: string | null
khoiLuongNganSach: number
khoiLuongThiCong: number
donGiaNganSach: number
thanhTienNganSach: number
order: number
ghiChu: string | null
quotes: PeQuote[]
}
export type PeApproval = {
id: string
fromPhase: number
toPhase: number
approverUserId: string | null
approverName: string | null
decision: number
comment: string | null
approvedAt: string
}
export type PeWorkflowSummary = {
policyName: string
policyDescription: string
activePhases: number[]
nextPhases: number[]
}
export type PeChangelog = {
id: string
entityType: number
entityId: string | null
action: number
phaseAtChange: number | null
userId: string | null
userName: string | null
summary: string | null
fieldChangesJson: string | null
contextNote: string | null
createdAt: string
}
export type PeDetailBundle = {
id: string
maPhieu: string | null
type: number
phase: number
tenGoiThau: string
diaDiem: string | null
moTa: string | null
projectId: string
projectName: string
departmentId: string | null
departmentName: string | null
drafterUserId: string | null
drafterName: string | null
selectedSupplierId: string | null
selectedSupplierName: string | null
contractId: string | null
paymentTerms: string | null
slaDeadline: string | null
createdAt: string
updatedAt: string | null
suppliers: PeSupplier[]
details: PeDetailRow[]
approvals: PeApproval[]
workflow: PeWorkflowSummary
}