→ /system/pe-workflows/
+ const peWfMatch = key.match(/^PeWf_(.+)$/)
+ if (peWfMatch) {
+ const code = peWfMatch[1]
+ if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn') return `/system/pe-workflows/${code}`
+ }
+
return null
}
diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx
new file mode 100644
index 0000000..6b4dbd3
--- /dev/null
+++ b/fe-admin/src/components/pe/PeDetailTabs.tsx
@@ -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('info')
+ const navigate = useNavigate()
+ const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
+
+ return (
+
+
+
+
+ {evaluation.tenGoiThau}
+
+ {PurchaseEvaluationPhaseLabel[evaluation.phase]}
+
+
+
+ {evaluation.maPhieu ?? '—'}
+ ·
+ {PurchaseEvaluationTypeLabel[evaluation.type]}
+ ·
+ {evaluation.projectName}
+ {evaluation.drafterName && <>·Soạn: {evaluation.drafterName}>}
+
+
+
+ {isDraft && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ {tab === 'info' && }
+ {tab === 'suppliers' && }
+ {tab === 'items' && }
+ {tab === 'approvals' && }
+ {tab === 'history' && }
+
+
+ )
+}
+
+// ===== Tab: Thông tin =====
+function InfoTab({ ev }: { ev: PeDetailBundle }) {
+ return (
+
+
+
+
+
+
+
+ {ev.contractId && (
+ ✓ Xem HĐ} />
+ )}
+
+ )
+}
+
+function Field({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
+
+// ===== Tab: NCC =====
+function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
+ const qc = useQueryClient()
+ const [open, setOpen] = useState(false)
+ const [editRow, setEditRow] = useState(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 (
+
+
+
+
+ {ev.suppliers.length === 0 ? (
+ Chưa có NCC. Thêm NCC để bắt đầu so sánh giá.
+ ) : (
+
+
+
+
+ NCC
+ Hiển thị
+ Liên hệ
+ Điều khoản TT
+ Ghi chú
+
+
+
+
+ {ev.suppliers.map(s => (
+
+ {s.supplierName}
+ {s.displayName ?? '—'}
+
+ {s.contactName && {s.contactName}}
+ {s.contactPhone && {s.contactPhone}}
+ {s.contactEmail && {s.contactEmail}}
+
+ {s.paymentTermText ?? '—'}
+ {s.note ?? '—'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {open && setOpen(false)} />}
+ {editRow && setEditRow(null)} />}
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+// ===== 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(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 (
+
+
+
+ {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á.`}
+
+
+
+
+ {ev.details.length === 0 ? (
+ Chưa có hạng mục.
+ ) : (
+
+
+
+
+ Hạng mục
+ KL
+ ĐG ngân sách
+ TT ngân sách
+ {ev.suppliers.map(s => (
+
+ {s.displayName ?? s.supplierName}
+
+ ))}
+
+
+
+
+ {ev.details.map(d => (
+
+
+ {d.groupCode} {d.noiDung}
+ {d.groupName} · {d.donViTinh ?? ''}
+
+ {d.khoiLuongNganSach}
+ {fmtMoney(d.donGiaNganSach)}
+ {fmtMoney(d.thanhTienNganSach)}
+ {ev.suppliers.map(s => {
+ const q = quoteKey(d.id, s.id)
+ return (
+ 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) : —}
+
+ )
+ })}
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {addOpen && setAddOpen(false)} />}
+ {editDetail && setEditDetail(null)} />}
+ {quoteEdit && (
+ setQuoteEdit(null)}
+ />
+ )}
+
+ )
+}
+
+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) => {
+ 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 (
+
+ )
+}
+
+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) => {
+ 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 (
+
+ )
+}
+
+// ===== Tab: Duyệt =====
+function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
+ if (ev.approvals.length === 0) return Chưa có bước duyệt nào.
+ return (
+
+ {ev.approvals.map(a => (
+ -
+
+
+
+ {PurchaseEvaluationPhaseLabel[a.fromPhase]}
+
+ →
+
+ {PurchaseEvaluationPhaseLabel[a.toPhase]}
+
+
+ {new Date(a.approvedAt).toLocaleString('vi-VN')}
+
+
+ {a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
+
+
+ ))}
+
+ )
+}
+
+// ===== Tab: Lịch sử =====
+function HistoryTab({ ev }: { ev: PeDetailBundle }) {
+ const logs = useQuery({
+ queryKey: ['pe-changelog', ev.id],
+ queryFn: async () => (await api.get(`/purchase-evaluations/${ev.id}/changelogs`)).data,
+ })
+ if (logs.isLoading) return Đang tải…
+ if (!logs.data || logs.data.length === 0) return Chưa có lịch sử.
+ return (
+
+ {logs.data.map(l => (
+ -
+
+ {l.userName ?? 'Hệ thống'}
+ {new Date(l.createdAt).toLocaleString('vi-VN')}
+
+ {l.summary}
+ {l.contextNote && {l.contextNote}}
+
+ ))}
+
+ )
+}
diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx
new file mode 100644
index 0000000..a2208cd
--- /dev/null
+++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx
@@ -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(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 (
+
+
+ Quy trình
+ {evaluation.workflow.policyDescription}
+
+
+
+ {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 (
+ -
+
+
+ {p}
+
+ {PurchaseEvaluationPhaseLabel[p]}
+ {isCurrent && ● hiện tại}
+ {isPast && ✓}
+
+
+ )
+ })}
+
+
+ {next.length > 0 && (
+
+
+
+ {next.map(p => (
+
+ ))}
+
+
+ )}
+
+ {target !== null && (
+
+ )}
+
+ )
+}
+
+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
+}
diff --git a/fe-admin/src/lib/menuKeys.ts b/fe-admin/src/lib/menuKeys.ts
index 533f746..cb6565c 100644
--- a/fe-admin/src/lib/menuKeys.ts
+++ b/fe-admin/src/lib/menuKeys.ts
@@ -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]
diff --git a/fe-admin/src/pages/pe/PurchaseEvaluationCreatePage.tsx b/fe-admin/src/pages/pe/PurchaseEvaluationCreatePage.tsx
new file mode 100644
index 0000000..b385430
--- /dev/null
+++ b/fe-admin/src/pages/pe/PurchaseEvaluationCreatePage.tsx
@@ -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(`/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 (
+
+
+
+
+ {editId ? 'Sửa phiếu Duyệt NCC' : 'Tạo phiếu Duyệt NCC mới'}
+
+
+
+
+
+
+
+
+
+
+
+ setForm({ ...form, tenGoiThau: e.target.value })}
+ placeholder="vd Cung cấp bê tông"
+ />
+
+
+
+
+
+
+
+
+
+ setForm({ ...form, diaDiem: e.target.value })}
+ placeholder="Lô K, KCN Lộc An - Bình Sơn..."
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx
new file mode 100644
index 0000000..a953083
--- /dev/null
+++ b/fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx
@@ -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('/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>('/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(`/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 (
+
+
+
+
+ {headerTitle}
+
+ {list.data?.total ?? 0}
+
+
+
+
+
+
+ {/* Panel 1: List */}
+
+
+ {/* Panel 2: Detail tabs */}
+
+ {!selectedId && (
+
+ )}
+ {selectedId && detail.isLoading && Đang tải…}
+ {selectedId && detail.data && (
+ setParam('id', null)}
+ onDelete={() => del.mutate(detail.data!.id)}
+ />
+ )}
+
+
+ {/* Panel 3: Workflow + history */}
+
+
+
+ )
+}
+
+// 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(`/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 Đang tải…
+ if (!detail.data) return Không tìm thấy phiếu.
+ return (
+
+ navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
+
+
+ )
+}
+
diff --git a/fe-admin/src/types/purchaseEvaluation.ts b/fe-admin/src/types/purchaseEvaluation.ts
new file mode 100644
index 0000000..bc63cb7
--- /dev/null
+++ b/fe-admin/src/types/purchaseEvaluation.ts
@@ -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 = {
+ 1: 'Duyệt NCC',
+ 2: 'Duyệt NCC - Phương án',
+}
+
+export const PurchaseEvaluationTypeCode: Record = {
+ 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 = {
+ 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 = {
+ 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
+}
diff --git a/fe-user/src/App.tsx b/fe-user/src/App.tsx
index 40d268d..abf925d 100644
--- a/fe-user/src/App.tsx
+++ b/fe-user/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
+ } />
+ } />
} />
= {
- 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__ 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 = { 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
}
diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx
new file mode 100644
index 0000000..6b4dbd3
--- /dev/null
+++ b/fe-user/src/components/pe/PeDetailTabs.tsx
@@ -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('info')
+ const navigate = useNavigate()
+ const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
+
+ return (
+
+
+
+
+ {evaluation.tenGoiThau}
+
+ {PurchaseEvaluationPhaseLabel[evaluation.phase]}
+
+
+
+ {evaluation.maPhieu ?? '—'}
+ ·
+ {PurchaseEvaluationTypeLabel[evaluation.type]}
+ ·
+ {evaluation.projectName}
+ {evaluation.drafterName && <>·Soạn: {evaluation.drafterName}>}
+
+
+
+ {isDraft && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ {tab === 'info' && }
+ {tab === 'suppliers' && }
+ {tab === 'items' && }
+ {tab === 'approvals' && }
+ {tab === 'history' && }
+
+
+ )
+}
+
+// ===== Tab: Thông tin =====
+function InfoTab({ ev }: { ev: PeDetailBundle }) {
+ return (
+
+
+
+
+
+
+
+ {ev.contractId && (
+ ✓ Xem HĐ} />
+ )}
+
+ )
+}
+
+function Field({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
+
+// ===== Tab: NCC =====
+function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
+ const qc = useQueryClient()
+ const [open, setOpen] = useState(false)
+ const [editRow, setEditRow] = useState(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 (
+
+
+
+
+ {ev.suppliers.length === 0 ? (
+ Chưa có NCC. Thêm NCC để bắt đầu so sánh giá.
+ ) : (
+
+
+
+
+ NCC
+ Hiển thị
+ Liên hệ
+ Điều khoản TT
+ Ghi chú
+
+
+
+
+ {ev.suppliers.map(s => (
+
+ {s.supplierName}
+ {s.displayName ?? '—'}
+
+ {s.contactName && {s.contactName}}
+ {s.contactPhone && {s.contactPhone}}
+ {s.contactEmail && {s.contactEmail}}
+
+ {s.paymentTermText ?? '—'}
+ {s.note ?? '—'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {open && setOpen(false)} />}
+ {editRow && setEditRow(null)} />}
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+// ===== 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(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 (
+
+
+
+ {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á.`}
+
+
+
+
+ {ev.details.length === 0 ? (
+ Chưa có hạng mục.
+ ) : (
+
+
+
+
+ Hạng mục
+ KL
+ ĐG ngân sách
+ TT ngân sách
+ {ev.suppliers.map(s => (
+
+ {s.displayName ?? s.supplierName}
+
+ ))}
+
+
+
+
+ {ev.details.map(d => (
+
+
+ {d.groupCode} {d.noiDung}
+ {d.groupName} · {d.donViTinh ?? ''}
+
+ {d.khoiLuongNganSach}
+ {fmtMoney(d.donGiaNganSach)}
+ {fmtMoney(d.thanhTienNganSach)}
+ {ev.suppliers.map(s => {
+ const q = quoteKey(d.id, s.id)
+ return (
+ 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) : —}
+
+ )
+ })}
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {addOpen && setAddOpen(false)} />}
+ {editDetail && setEditDetail(null)} />}
+ {quoteEdit && (
+ setQuoteEdit(null)}
+ />
+ )}
+
+ )
+}
+
+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) => {
+ 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 (
+
+ )
+}
+
+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) => {
+ 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 (
+
+ )
+}
+
+// ===== Tab: Duyệt =====
+function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
+ if (ev.approvals.length === 0) return Chưa có bước duyệt nào.
+ return (
+
+ {ev.approvals.map(a => (
+ -
+
+
+
+ {PurchaseEvaluationPhaseLabel[a.fromPhase]}
+
+ →
+
+ {PurchaseEvaluationPhaseLabel[a.toPhase]}
+
+
+ {new Date(a.approvedAt).toLocaleString('vi-VN')}
+
+
+ {a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
+
+
+ ))}
+
+ )
+}
+
+// ===== Tab: Lịch sử =====
+function HistoryTab({ ev }: { ev: PeDetailBundle }) {
+ const logs = useQuery({
+ queryKey: ['pe-changelog', ev.id],
+ queryFn: async () => (await api.get(`/purchase-evaluations/${ev.id}/changelogs`)).data,
+ })
+ if (logs.isLoading) return Đang tải…
+ if (!logs.data || logs.data.length === 0) return Chưa có lịch sử.
+ return (
+
+ {logs.data.map(l => (
+ -
+
+ {l.userName ?? 'Hệ thống'}
+ {new Date(l.createdAt).toLocaleString('vi-VN')}
+
+ {l.summary}
+ {l.contextNote && {l.contextNote}}
+
+ ))}
+
+ )
+}
diff --git a/fe-user/src/components/pe/PeWorkflowPanel.tsx b/fe-user/src/components/pe/PeWorkflowPanel.tsx
new file mode 100644
index 0000000..a2208cd
--- /dev/null
+++ b/fe-user/src/components/pe/PeWorkflowPanel.tsx
@@ -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(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 (
+
+
+ Quy trình
+ {evaluation.workflow.policyDescription}
+
+
+
+ {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 (
+ -
+
+
+ {p}
+
+ {PurchaseEvaluationPhaseLabel[p]}
+ {isCurrent && ● hiện tại}
+ {isPast && ✓}
+
+
+ )
+ })}
+
+
+ {next.length > 0 && (
+
+
+
+ {next.map(p => (
+
+ ))}
+
+
+ )}
+
+ {target !== null && (
+
+ )}
+
+ )
+}
+
+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
+}
diff --git a/fe-user/src/lib/menuKeys.ts b/fe-user/src/lib/menuKeys.ts
index 533f746..cb6565c 100644
--- a/fe-user/src/lib/menuKeys.ts
+++ b/fe-user/src/lib/menuKeys.ts
@@ -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]
diff --git a/fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx b/fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx
new file mode 100644
index 0000000..b385430
--- /dev/null
+++ b/fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx
@@ -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(`/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 (
+
+
+
+
+ {editId ? 'Sửa phiếu Duyệt NCC' : 'Tạo phiếu Duyệt NCC mới'}
+
+
+
+
+
+
+
+
+
+
+
+ setForm({ ...form, tenGoiThau: e.target.value })}
+ placeholder="vd Cung cấp bê tông"
+ />
+
+
+
+
+
+
+
+
+
+ setForm({ ...form, diaDiem: e.target.value })}
+ placeholder="Lô K, KCN Lộc An - Bình Sơn..."
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx b/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx
new file mode 100644
index 0000000..a953083
--- /dev/null
+++ b/fe-user/src/pages/pe/PurchaseEvaluationsListPage.tsx
@@ -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('/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>('/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(`/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 (
+
+
+
+
+ {headerTitle}
+
+ {list.data?.total ?? 0}
+
+
+
+
+
+
+ {/* Panel 1: List */}
+
+
+ {/* Panel 2: Detail tabs */}
+
+ {!selectedId && (
+
+ )}
+ {selectedId && detail.isLoading && Đang tải…}
+ {selectedId && detail.data && (
+ setParam('id', null)}
+ onDelete={() => del.mutate(detail.data!.id)}
+ />
+ )}
+
+
+ {/* Panel 3: Workflow + history */}
+
+
+
+ )
+}
+
+// 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(`/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 Đang tải…
+ if (!detail.data) return Không tìm thấy phiếu.
+ return (
+
+ navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
+
+
+ )
+}
+
diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts
new file mode 100644
index 0000000..bc63cb7
--- /dev/null
+++ b/fe-user/src/types/purchaseEvaluation.ts
@@ -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 = {
+ 1: 'Duyệt NCC',
+ 2: 'Duyệt NCC - Phương án',
+}
+
+export const PurchaseEvaluationTypeCode: Record = {
+ 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 = {
+ 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 = {
+ 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
+}