diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index 4786701..9ef83e9 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -18,6 +18,8 @@ import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage' import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage' import { ReportsPage } from '@/pages/ReportsPage' import { UsersPage } from '@/pages/system/UsersPage' +import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage' +import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage' function App() { return ( @@ -47,6 +49,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> _ cho module Duyệt NCC + 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` + } + // PE workflow admin leaf: PeWf_ → /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á.

+ ) : ( +
+ + + + + + + + + + + + + {ev.suppliers.map(s => ( + + + + + + + + + ))} + +
NCCHiển thịLiên hệĐiều khoản TTGhi chú
{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 ( + + + + } + > +
+
+ + +
+
+
setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" />
+
setForm({ ...form, paymentTermText: e.target.value })} placeholder="vd 30 ngày, 300tr" />
+
setForm({ ...form, contactName: e.target.value })} />
+
setForm({ ...form, contactPhone: e.target.value })} />
+
setForm({ ...form, contactEmail: e.target.value })} />
+
setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." />
+
+
+
+ ) +} + +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 ( + + + + } + > +
+
setForm({ ...form, displayName: e.target.value })} />
+
setForm({ ...form, paymentTermText: e.target.value })} />
+
setForm({ ...form, contactName: e.target.value })} />
+
setForm({ ...form, contactPhone: e.target.value })} />
+
setForm({ ...form, contactEmail: e.target.value })} />
+
setForm({ ...form, note: e.target.value })} />
+
+
+ ) +} + +// ===== 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.

+ ) : ( +
+ + + + + + + + {ev.suppliers.map(s => ( + + ))} + + + + + {ev.details.map(d => ( + + + + + + {ev.suppliers.map(s => { + const q = quoteKey(d.id, s.id) + return ( + + ) + })} + + + ))} + +
Hạng mụcKLĐG ngân sáchTT ngân sách + {s.displayName ?? s.supplierName} +
+
{d.groupCode} {d.noiDung}
+
{d.groupName} · {d.donViTinh ?? ''}
+
{d.khoiLuongNganSach}{fmtMoney(d.donGiaNganSach)}{fmtMoney(d.thanhTienNganSach)} 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 ( + + + + } + > +
+
+
setForm({ ...form, groupCode: e.target.value })} />
+
setForm({ ...form, groupName: e.target.value })} placeholder="Bê tông / Phụ gia..." />
+
setForm({ ...form, itemCode: e.target.value })} />
+
setForm({ ...form, noiDung: e.target.value })} />
+
setForm({ ...form, donViTinh: e.target.value })} />
+
updateAndRecalc({ khoiLuongNganSach: Number(e.target.value) })} />
+
setForm({ ...form, khoiLuongThiCong: Number(e.target.value) })} />
+
updateAndRecalc({ donGiaNganSach: Number(e.target.value) })} />
+
setForm({ ...form, thanhTienNganSach: Number(e.target.value) })} />
+
setForm({ ...form, ghiChu: e.target.value })} />
+
+
+
+ ) +} + +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 ( + + {existing && } + + + } + > +
+

Hạng mục: {itemName} · KL {khoiLuong}

+
+
updateAndRecalc({ chuaVat: Number(e.target.value) })} />
+
setForm({ ...form, bgVat: Number(e.target.value) })} />
+
setForm({ ...form, thanhTien: Number(e.target.value) })} />
+
+ +
setForm({ ...form, note: e.target.value })} />
+
+
+ ) +} + +// ===== 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 => ( +
  1. +
    +
    + + {PurchaseEvaluationPhaseLabel[a.fromPhase]} + + + + {PurchaseEvaluationPhaseLabel[a.toPhase]} + +
    + {new Date(a.approvedAt).toLocaleString('vi-VN')} +
    +
    + {a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`} +
    +
  2. + ))} +
+ ) +} + +// ===== 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 => ( +
  1. +
    + {l.userName ?? 'Hệ thống'} + {new Date(l.createdAt).toLocaleString('vi-VN')} +
    +
    {l.summary}
    + {l.contextNote &&
    {l.contextNote}
    } +
  2. + ))} +
+ ) +} 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 ( +
  1. +
    + + {p} + + {PurchaseEvaluationPhaseLabel[p]} + {isCurrent && ● hiện tại} + {isPast && } +
    +
  2. + ) + })} +
+ + {next.length > 0 && ( +
+ +
+ {next.map(p => ( + + ))} +
+
+ )} + + {target !== null && ( + setTarget(null)} + title={`Chuyển → ${PurchaseEvaluationPhaseLabel[target]}`} + footer={<> + + + } + > + +