// Detail content cho 1 phiếu Duyệt NCC. Flat render (no tabs): Thông tin +
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
// → PeApprovalsSection + PeHistorySection).
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useIsFetching, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { SearchableSelect } from '@/components/ui/SearchableSelect'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog'
import { PePipelineStrip } from './PePipelineStrip'
import {
PeAttachmentPurpose,
PeAttachmentPurposeLabel,
PeDepartmentKind,
PeDepartmentKindLabel,
PeDisplayStatusColor,
PeDisplayStatusLabel,
PurchaseEvaluationPhase,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
getPeDisplayStatus,
isEditablePhase,
type PeApproval,
type PeAttachment,
type PeChangelog,
type PeDepartmentOpinion,
type PeDetailBundle,
type PeDetailRow,
type PeLevelOpinion,
type PeQuote,
type PeSupplier,
} from '@/types/purchaseEvaluation'
import { SupplierType, SupplierTypeLabel } from '@/types/master'
import type { Supplier } from '@/types/master'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
// Session 20 turn 4 — input helpers cho NCC/Quote inline form.
// VND format dùng convention VN dấu chấm ngàn (1.000.000). Strip non-digit
// khi parse user input → number. Empty/0 → empty string để placeholder hiện.
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '')
// Validation cơ bản FE — empty OK (optional fields). BE FluentValidation
// chưa enforce, FE check để user nhập sai biết ngay.
const PHONE_RE = /^0\d{9,10}$/ // VN: bắt đầu 0, 10-11 digits sau khi strip space/dash/dot
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const isValidPhone = (s: string): boolean => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, ''))
const isValidEmail = (s: string): boolean => !s || EMAIL_RE.test(s)
// Session 20 turn 8: trang trí 5 NCC khác màu (cycle theo index). Winner override
// thành emerald nổi bật. Literal Tailwind class để JIT scan compile được.
const NCC_PALETTES = [
'border-l-blue-400 bg-blue-50/40',
'border-l-purple-400 bg-purple-50/40',
'border-l-sky-400 bg-sky-50/40',
'border-l-teal-400 bg-teal-50/40',
'border-l-pink-400 bg-pink-50/40',
] as const
// Giá chào thầu của NCC/TP được chọn (winner) = sum quotes.thanhTien của winner
// supplier-row. Single source of truth — Section 3 (ChonNccSection) + pre-check
// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn
// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá.
function computeGiaChaoThau(ev: PeDetailBundle): number | null {
// [Mig 58] Multi-winner: null khi CHƯA chọn đơn vị nào; else = winnerQuoteTotal (BE
// SUM ThanhTien MỌI đơn vị isWinner — single hoặc liên-danh ≥2). 0 = winner chưa báo giá.
if (!ev.suppliers.some(s => s.isWinner)) return null
return ev.winnerQuoteTotal
}
// [S116 anh Kiệt] "Có giá là được": phiếu gửi-duyệt được khi có ≥1 báo giá ĐƯỢC CHỌN
// (IsSelected) với thanhTien !== 0. Giá ÂM = phát-sinh-giảm / hoàn tiền NCC = giá THẬT,
// KHÔNG phải lỗi. Thay điều-kiện computeGiaChaoThau > 0 cũ: SUM không phân biệt all-zero
// (chưa nhập) vs net-zero (+100,-100=0 nhưng có giá thật) → net-zero bị chặn oan. Mirror
// BE selectedThanhTien = quotes WHERE IsSelected. Quote sống ở details[].quotes (KHÔNG
// suppliers[].quotes); IsSelected = báo giá được tick trúng trong hàng hạng mục.
function hasRealSelectedQuote(ev: PeDetailBundle): boolean {
return ev.details.some(d => d.quotes.some(q => q.isSelected && q.thanhTien !== 0))
}
// Main detail content — flat render 3 section không tabs.
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
//
// `mode` (2026-05-07):
// - 'detail' (default): full UX — Section 5 Ý kiến 4PB editable theo readOnly.
// Dùng ở leaf "Danh sách" + "Duyệt" (3-panel pages).
// - 'workspace': dùng ở leaf "Thao tác" (2-panel workspace). Section 5 LUÔN
// disabled (Q5 user — ý kiến nhập khi duyệt, không phải workspace nhập liệu).
// Workflow Panel + Approvals + History KHÔNG render trong PeDetailTabs (luôn
// ở caller PeWorkflowPanel — workspace caller skip render Panel 3 hoàn toàn).
export function PeDetailTabs({
evaluation,
onBack,
onDelete,
readOnly = false,
mode = 'detail',
autoEditHeader = false,
}: {
evaluation: PeDetailBundle
onBack: () => void
onDelete: () => void
/** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
readOnly?: boolean
/** 'workspace' = Section 5 LUÔN disabled (ý kiến nhập ở leaf Duyệt). */
mode?: 'detail' | 'workspace'
/** Auto open Section 1 InfoTab in edit mode khi mount — triggered từ pencil icon Panel 1 */
autoEditHeader?: boolean
}) {
const qc = useQueryClient()
// canEditPhase: bao gồm cả TraLai (user 2026-05-07). Header bar action
// buttons "Sửa header" + "Xóa" + "Đóng" workspace mode đã chuyển xuống bottom
// action bar (B11+ user 2026-05-07).
const canEditPhase = isEditablePhase(evaluation.phase)
const opinionsReadOnly = readOnly || mode === 'workspace'
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
// S69 — cờ gấp: role quyết định nút nào hiện. PRO (Procurement) → cờ ĐỎ
// (isUrgentByPro), CCM (CostControl) → cờ XANH (isUrgentByCcm), Admin → cả 2.
// BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security.
const isPro = currentUser?.roles?.includes('Procurement') ?? false
const isCcm = currentUser?.roles?.includes('CostControl') ?? false
// [S77 Tra Sol/anh Kiệt — chốt] BẤT ĐỐI XỨNG: GẮN = NV chức năng (ai làm nấy gắn);
// GỠ = chỉ Trưởng phòng (DeptManager)/Admin (tránh NV khác lỡ tay gỡ). Nút phụ thuộc
// trạng thái hiện tại: đã gấp → cần quyền GỠ; chưa gấp → cần quyền GẮN.
const isDeptManager = currentUser?.roles?.includes('DeptManager') ?? false
// [S85 anh Kiệt] Người KHAI phiếu tự GỠ cờ gấp của mình (nới GỠ +drafter; GẮN giữ role-only).
const isDrafter = currentUser?.id != null && evaluation.drafterUserId === currentUser.id
const canToggleProUrgent = evaluation.isUrgentByPro
? (isAdmin || (isPro && isDeptManager) || isDrafter)
: (isAdmin || isPro)
const canToggleCcmUrgent = evaluation.isUrgentByCcm
? (isAdmin || (isCcm && isDeptManager) || isDrafter)
: (isAdmin || isCcm)
const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorMatchesLevel = isAdmin
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
const approverEditMode = evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
// Mig 29 (S21 t5) — read F3 từ currentLevelOptions (per-NV slot)
&& (evaluation.currentLevelOptions?.allowApproverEditDetails ?? false)
&& actorMatchesLevel
const itemsReadOnly = readOnly && !approverEditMode
// "Lưu & Gửi Duyệt" workspace mode (user 2026-05-07): trigger transition
// sang phase tiếp theo (= Đã gửi duyệt). nextPhases[0] thường là ChoPurchasing
// (skip TuChoi). Sau success → toast + invalidate + onBack đóng workspace.
// Mig 31 (S23 t1) — F2 Drafter-from-Nháp semantic deprecated. skipToFinal moved
// sang Approver scope ChoDuyet (per-Level slot — xem PeWorkflowPanel).
const submitForApproval = useMutation({
mutationFn: async () => {
const next = evaluation.workflow.nextPhases.find(p => p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
if (!next) throw new Error('Không có phase tiếp theo để gửi duyệt')
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: next,
decision: 1,
comment: null,
})
},
onSuccess: () => {
toast.success('Đã gửi duyệt phiếu — chuyển sang quy trình duyệt.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
onBack()
},
onError: e => toast.error(getErrorMessage(e)),
})
// S69 — toggle cờ gấp (PUT /urgent { isUrgent }). BE role-aware: PRO flip cờ ĐỎ,
// CCM flip cờ XANH, Admin set CẢ 2. FE optimistic + invalidate detail + list.
const toggleUrgent = useMutation({
mutationFn: async (isUrgent: boolean) =>
api.put(`/purchase-evaluations/${evaluation.id}/urgent`, { isUrgent }),
onSuccess: (_d, isUrgent) => {
toast.success(isUrgent ? 'Đã đánh dấu phiếu GẤP.' : 'Đã bỏ đánh dấu gấp.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
// Pre-check data-completeness cho action "Lưu & Gửi Duyệt" (S60 — anh Kiệt chốt).
// CHỈ áp cho action gửi duyệt — liệt kê TẤT CẢ mục thiếu của Section 3 "Đơn vị
// NCC/TP được chọn". Predicate khớp BE guard TransitionAsync (em main song song).
// Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch.
const missingForApproval = useMemo(() => {
const missing: string[] = []
// 1. Chưa chọn Đơn vị NCC/TP ([Mig 58] multi-winner: ≥1 đơn vị isWinner; selectedSupplierId
// null khi liên-danh ≥2 nên KHÔNG dùng nó — mirror BE submit-guard IsWinner).
if (!evaluation.suppliers.some(s => s.isWinner)) {
missing.push("Chưa chọn Đơn vị NCC/TP")
} else {
// 2. [S116 anh Kiệt] Đơn vị được chọn CHƯA NHẬP giá chào thầu nào (mọi báo giá
// được chọn = 0). "Có giá là được": giá ÂM (phát-sinh-giảm / hoàn tiền NCC) = giá
// THẬT → gửi được; net-zero có giá thật (+100,-100) cũng gửi được. Chỉ chặn khi
// TOÀN BỘ = 0 (chưa nhập gì). Mirror BE selectedThanhTien = quotes WHERE IsSelected.
if (!hasRealSelectedQuote(evaluation)) missing.push("Đơn vị được chọn chưa có giá chào thầu")
}
// 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập).
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
// [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) —
// mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được).
// [Mig 58 — anh Kiệt FDC] NS = 0 là HỢP LỆ (gói cho-không / vật tư cấp sẵn). Chỉ
// block khi CHƯA khai (null). Mirror BE submit-guard nới lỏng cùng đợt.
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
if (effBudget == null) {
missing.push("Chưa nhập Ngân sách kỳ này")
}
// 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3).
// S78 — loại file "đính kèm khi duyệt" (purpose=ApprovalAttachment, supplierId=null)
// khỏi check: nó KHÔNG phải bảng so sánh, không được false-pass submit-guard khi
// phiếu Trả-lại re-submit (lúc đó đã tồn tại file khi-duyệt từ vòng trước).
if (!evaluation.attachments?.some(
a => a.purchaseEvaluationSupplierId === null
&& a.purpose !== PeAttachmentPurpose.ApprovalAttachment,
)) {
missing.push("Chưa đính kèm Bảng so sánh")
}
return missing
}, [evaluation])
// [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
const submitChecklist = useMemo(() => {
const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
// [S116 anh Kiệt] "đã nhập giá" = có ≥1 báo giá được chọn !== 0 (âm = giá thật). Không
// dùng SUM > 0 (net-zero có giá thật bị false-đỏ). Mirror BE quotes WHERE IsSelected.
const hasRealQuote = supplierOk && hasRealSelectedQuote(evaluation)
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
return [
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
{ label: 'Đã nhập giá chào thầu', ok: hasRealQuote },
{ label: 'Ngân sách kỳ này', ok: effBudget != null },
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
]
}, [evaluation])
const canSubmitForApproval = mode === 'workspace'
&& canEditPhase
&& !readOnly
&& forwardPhase != null
&& missingForApproval.length === 0
// Tooltip reason cho button disabled (giúp diagnose tại sao "Lưu & Gửi Duyệt"
// không bấm được — user feedback 2026-05-07). Reason cũ (workspace/canEditPhase/
// readOnly/forwardPhase) giữ nguyên; append data-completeness check S60 sau cùng.
const submitDisabledReason = !canEditPhase
? `Phiếu đã ở phase ${PurchaseEvaluationPhaseLabel[evaluation.phase]} — chỉ Bản nháp / Trả lại mới sửa + gửi được.`
: readOnly
? 'Chế độ chỉ đọc.'
: !forwardPhase
? `Workflow không có phase tiếp theo từ ${PurchaseEvaluationPhaseLabel[evaluation.phase]}. Liên hệ admin kiểm tra cấu hình quy trình.`
: missingForApproval.length > 0
? `Chưa đủ thông tin mục 3 'Đơn vị NCC/TP được chọn':\n${missingForApproval.map(m => `• ${m}`).join('\n')}`
: null
return (
{/* [S159] dải 4 giai đoạn toàn trình — owner vẽ tab lên ảnh phiếu (phiếu PE ⇒ GĐ1 active) */}
{evaluation.tenGoiThau}
{/* Display status meta (Bản nháp / Đã gửi duyệt / Đã duyệt / Từ chối)
— phase chi tiết hiện ở Workflow timeline Panel 3. */}
{PeDisplayStatusLabel[getPeDisplayStatus(evaluation.phase)]}
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
{/* S69 — badge cờ gấp: ĐỎ (PRO) / XANH-lá (CCM). Hiển thị độc lập. */}
{evaluation.isUrgentByPro && (
🔴 GẤP (PRO)
)}
{evaluation.isUrgentByCcm && (
🟢 GẤP (CCM)
)}
{readOnly && (
chế độ duyệt
)}
{evaluation.maPhieu ?? '—'}
·
{PurchaseEvaluationTypeLabel[evaluation.type]}
·
{evaluation.projectName}
{/* S57bis — phiếu dạng "Dự án – Hạng mục công việc" (lời sếp) */}
{evaluation.workItemName && <>– {evaluation.workItemName} >}
{evaluation.drafterName && <>· Soạn: {evaluation.drafterName} >}
{/* S69 — nút bật/tắt cờ gấp (theo role) + hint giá trị gói vs ngưỡng CEO. */}
{(canToggleProUrgent || canToggleCcmUrgent || evaluation.ceoApprovalThreshold != null) && (
{canToggleProUrgent && (
toggleUrgent.mutate(!evaluation.isUrgentByPro)}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition disabled:opacity-50',
evaluation.isUrgentByPro
? 'border-red-300 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-slate-300 bg-white text-slate-600 hover:border-red-300 hover:text-red-700',
)}
title="Cờ ĐỎ — Phòng Cung ứng (PRO) đánh dấu gấp"
>
🔴 {evaluation.isUrgentByPro ? 'Bỏ gấp (PRO)' : 'Đánh dấu GẤP (PRO)'}
)}
{canToggleCcmUrgent && (
toggleUrgent.mutate(!evaluation.isUrgentByCcm)}
className={cn(
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition disabled:opacity-50',
evaluation.isUrgentByCcm
? 'border-green-300 bg-green-50 text-green-700 hover:bg-green-100'
: 'border-slate-300 bg-white text-slate-600 hover:border-green-300 hover:text-green-700',
)}
title="Cờ XANH — Phòng Kiểm soát chi phí (CCM) đánh dấu gấp"
>
🟢 {evaluation.isUrgentByCcm ? 'Bỏ gấp (CCM)' : 'Đánh dấu GẤP (CCM)'}
)}
{/* Hint giá trị gói vs ngưỡng CEO (chỉ khi workflow có set ngưỡng). */}
{evaluation.ceoApprovalThreshold != null && (
Giá trị gói: {fmtMoney(evaluation.winnerQuoteTotal)}đ
{' — '}
{evaluation.winnerQuoteTotal < evaluation.ceoApprovalThreshold ? (
CCM duyệt là xong
) : (
Cần CEO duyệt
)}
(ngưỡng {fmtMoney(evaluation.ceoApprovalThreshold)}đ)
)}
)}
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
giữ Đóng cho non-workspace view (Danh sách + Duyệt — readOnly). */}
{(readOnly || mode !== 'workspace') && (
← Đóng
)}
{/* S77 [Bích Phượng hỏi / anh] — phiếu Trả lại ở chế độ XEM không có nút gửi
(readOnly → "Lưu & Gửi Duyệt" chỉ hiện khi Sửa). Banner hướng dẫn gửi lại
để NV khỏi lạc ("bấm nộp lần 2 chỗ nào"). */}
{evaluation.phase === PurchaseEvaluationPhase.TraLai && readOnly && (
⚠️ Phiếu đã bị trả lại để chỉnh sửa
Để gửi duyệt lại : ra danh sách → bấm ✏️ Sửa phiếu
này (biểu tượng bút chì) → điều chỉnh nội dung cần sửa → bấm “Lưu & Gửi Duyệt →” ở
cuối phiếu. Lý do trả lại xem ở mục “Lịch sử” bên dưới.
)}
{/* Section layout (Session 20 Chunk B): Hạng mục nested expand chứa NCC
(tầng 1 = hạng mục, tầng 2 = NCC tham gia + báo giá inline). NCC
tham gia section riêng bỏ — gộp vào Section 2 expand panel. Tên
hạng mục + giá trị auto từ gói thầu (Chunk A BE seed). */}
{/* Mig 28 (S21 t4) — F3: itemsReadOnly cho phép approver edit Section 2 */}
{/* Plan Q S23 t7 — Drop mx-5 banner, full-width Section padding to
align với ItemsTab header (button "+ Thêm hạng mục" right-aligned
KHÔNG còn lệch khỏi banner inset gap). */}
{approverEditMode && readOnly && (
ⓘ Bạn được phép chỉnh sửa Hạng mục / NCC / Báo giá (workflow bật mode Approver edit).
Mọi thay đổi sẽ được ghi vào Lịch sử chỉnh sửa.
)}
{/* [anh Kiệt FDC S87+] Khối NỔI BẬT "4. Thông tin chọn thầu" — header xanh
giống bảng "giá trị thực hiện". Gom winner + c/d/e tách khỏi Section 3. */}
{mode === 'workspace' && (
Ý kiến + chữ ký auto đồng bộ khi NV duyệt phiếu — vào menu “Duyệt” để ký.
)}
{/* Mig 26 — V2 dynamic theo ApprovalWorkflowLevel. V1 phiếu cũ
fallback render 4 box CỨNG readOnly (data legacy giữ Mig 15). */}
{evaluation.approvalWorkflowId
?
: }
{/* S61 — Section "Điều chỉnh ngân sách" cũ (BudgetAdjustSection) XÓA:
module Budget bỏ hẳn, bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ trong Section 3
thay thế (PRO/CCM/drafter nhập trực tiếp theo capability flag BE). */}
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
- Xóa phiếu (CHỈ Bản nháp, soft-delete BE) — bên trái red
- Lưu (toast confirm, KHÔNG đóng workspace) — chính giữa ghost
- Lưu & Gửi Duyệt → (POST /transitions → next phase) — bên phải brand
User 2026-05-07. */}
{mode === 'workspace' && canEditPhase && !readOnly && (
<>
Cần đủ để gửi duyệt:
{submitChecklist.map(c => (
{c.ok ? '✓' : '✗'} {c.label}
))}
{/* Xóa phiếu — CHỉ DangSoanThao (bản nháp). TraLai không cho xóa
(đã có lịch sử workflow). Soft-delete qua DELETE /pe/:id endpoint
(AuditableEntity IsDeleted=true, không xóa hoàn toàn DB). */}
{evaluation.phase === PurchaseEvaluationPhase.DangSoanThao && (
{
if (confirm(`Xóa phiếu "${evaluation.tenGoiThau}"? Phiếu sẽ ẩn khỏi danh sách (soft-delete, không xóa hoàn toàn trong DB).`)) {
onDelete()
}
}}
className="gap-1.5 text-xs"
>
Xóa phiếu
)}
✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
{
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
toast.success('Đã lưu — sync server.')
}}
className="text-xs"
>
Lưu
{
if (!forwardPhase) return
const confirmMsg = `Gửi phiếu vào quy trình duyệt? Sẽ chuyển sang "${PurchaseEvaluationPhaseLabel[forwardPhase]}". Sau khi gửi sẽ KHÔNG sửa được nữa (trừ khi approver Trả lại).`
if (confirm(confirmMsg)) {
submitForApproval.mutate()
}
}}
disabled={!canSubmitForApproval || submitForApproval.isPending}
title={submitDisabledReason ?? `Gửi phiếu sang "${forwardPhase ? PurchaseEvaluationPhaseLabel[forwardPhase] : '?'}"`}
className="text-xs"
>
{submitForApproval.isPending ? 'Đang gửi…' : 'Lưu & Gửi Duyệt →'}
>
)}
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
// Session 20 turn 11: padding responsive cho laptop màn nhỏ — px-3 trên xs
// (tiết kiệm ~16px width), bump px-5 từ sm+ trở lên.
return (
)
}
// ===== Section 5 — Ý kiến 4 phòng ban =====
// Render 2x2 grid 4 box (Phê duyệt / CCM / MuaHàng / SM-PM). Mỗi box hiển
// thị Opinion text + chữ ký (UserName + SignedAt) nếu đã ký, hoặc form nhập
// + 2 button "Lưu" + "Lưu & Ký" khi chưa ký / readOnly=false.
function DepartmentOpinionsSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const KINDS: { kind: number; label: string }[] = [
{ kind: PeDepartmentKind.PheDuyet, label: PeDepartmentKindLabel[PeDepartmentKind.PheDuyet] },
{ kind: PeDepartmentKind.Ccm, label: PeDepartmentKindLabel[PeDepartmentKind.Ccm] },
{ kind: PeDepartmentKind.MuaHang, label: PeDepartmentKindLabel[PeDepartmentKind.MuaHang] },
{ kind: PeDepartmentKind.SmPm, label: PeDepartmentKindLabel[PeDepartmentKind.SmPm] },
]
return (
{KINDS.map(k => {
const existing = ev.departmentOpinions.find(o => o.kind === k.kind) ?? null
return (
)
})}
)
}
function OpinionBox({
evaluationId,
kind,
kindLabel,
existing,
readOnly,
}: {
evaluationId: string
kind: number
kindLabel: string
existing: PeDepartmentOpinion | null
readOnly: boolean
}) {
const qc = useQueryClient()
const [text, setText] = useState(existing?.opinion ?? '')
const isSigned = !!existing?.signedAt
const save = useMutation({
mutationFn: async (sign: boolean) =>
api.post(`/purchase-evaluations/${evaluationId}/opinions`, {
kind,
opinion: text || null,
sign,
}),
onSuccess: () => {
toast.success('Đã lưu ý kiến.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
{kindLabel}
{isSigned && (
Đã ký
)}
{readOnly ? (
<>
{existing?.opinion ?? — chưa có ý kiến }
{isSigned && (
Ký bởi {existing?.userName ?? '—'} · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}
)}
>
) : (
<>
)
}
// ===== Section 5 V2 — Ý kiến cấp duyệt dynamic (Mig 26 — Session 19) =====
//
// Render theo workflow đã pin: forEach Step → forEach Level (Cấp) → forEach
// approver (NV). Mỗi NV = 1 OpinionBox (read-only). Service ApproveV2Async
// auto sync comment khi duyệt (Q1=1B). Empty list → fallback message.
//
// Layout 5A: header "Bước N — Phòng X" badge + grid-cols-2 cho N approvers
// (wrap nếu N>2). Admin override badge khi SignedByUserId !== ApproverUserId.
// Session 20 Chunk C (revised): gộp opinions đồng cấp cùng Phòng → 1 wrapper box / Step,
// BÊN TRONG render từng NV đã duyệt thành các "ô vuông" card mirror visual S19
// (grid-cols-2 cards). User feedback turn 2: giữ visual ô vuông như trước.
//
// Counter fix turn 2: "Số bước duyệt" (= số Cấp / Step) KHÁC "số người duyệt trong
// 1 bước" (= tổng NV across Cấp, OR-of-N nên chỉ 1 NV/Cấp cần ký). Counter đúng
// hiển thị X/Y cấp đã duyệt + thông tin phụ tổng NV tham gia.
function LevelOpinionsSectionV2({ ev }: { ev: PeDetailBundle }) {
const flow = ev.approvalFlow
const opinions = ev.levelOpinions
if (!flow || flow.steps.length === 0) {
return (
Workflow chưa được cấu hình hoặc chưa có cấp duyệt nào.
)
}
return (
{flow.steps.map(step => {
const totalLevels = step.levels.length
const totalApprovers = step.levels.reduce((n, l) => n + l.approvers.length, 0)
const stepOpinions = opinions
.filter(o => o.stepOrder === step.order)
.slice()
.sort((a, b) => a.levelOrder - b.levelOrder || a.signedAt.localeCompare(b.signedAt))
const signedLevels = new Set(stepOpinions.map(o => o.levelOrder)).size
return (
)
})}
)
}
function StepOpinionsBox({
stepOrder, stepName, departmentName, totalLevels, totalApprovers, signedLevels, opinions,
}: {
stepOrder: number
stepName: string
departmentName?: string | null
totalLevels: number // số Cấp (bước duyệt nhỏ trong Step)
totalApprovers: number // tổng NV tham gia (FYI — OR-of-N nên không cần ký hết)
signedLevels: number // số Cấp đã có ít nhất 1 NV ký
opinions: PeLevelOpinion[]
}) {
return (
Bước {stepOrder} — {stepName}
{departmentName && (
{departmentName}
)}
{signedLevels}/{totalLevels} cấp đã duyệt · {totalApprovers} NV tham gia
{opinions.length === 0 ? (
— Chưa có ý kiến duyệt.
) : (
{opinions.map(o => )}
)}
)
}
function StepOpinionEntry({ opinion }: { opinion: PeLevelOpinion }) {
const isAdminOverride = opinion.signedByUserId !== opinion.approverUserId
return (
Cấp {opinion.levelOrder} — {opinion.approverFullName}
{isAdminOverride && (
⚠ Admin {opinion.signedByFullName} duyệt thay
)}
Đã duyệt
{opinion.comment}
{new Date(opinion.signedAt).toLocaleString('vi-VN')}
)
}
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
return (
Lịch sử duyệt ({ev.approvals.length})
)
}
export function PeHistorySection({ ev }: { ev: PeDetailBundle }) {
return (
Lịch sử thay đổi
)
}
// ===== Section 1 — Thông tin gói thầu (spec: a. Tên gói thầu / b. Dự án) =====
// Inline editable khi canEdit (=!readOnly && phase editable). Edit pencil button
// "Sửa" flip display ↔ form mode. Save dùng existing PUT /pe/:id endpoint với
// current entity values + new header fields. Dự án + Type LOCKED sau create —
// chỉ Tên/Địa điểm/Mô tả/Payment editable inline. autoEdit prop cho phép trigger
// edit mode từ pencil icon trong PeListPanel (URL flag ?editHeader=1).
// Phase editable = DangSoanThao + TraLai (user 2026-05-07).
function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boolean; autoEdit: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
const [editing, setEditing] = useState(autoEdit && canEdit)
const [tenGoiThau, setTenGoiThau] = useState(ev.tenGoiThau)
const [diaDiem, setDiaDiem] = useState(ev.diaDiem ?? '')
const [moTa, setMoTa] = useState(ev.moTa ?? '')
const [paymentTerms, setPaymentTerms] = useState(ev.paymentTerms ?? '')
// User 2026-05-07: re-trigger editing mode khi click pencil ở Panel 1 cho
// PHIẾU KHÁC (ev.id thay đổi) hoặc autoEdit prop change. useState init chỉ
// chạy mount-time → cần useEffect sync khi parent re-render với props mới.
useEffect(() => {
if (autoEdit && canEdit) {
setEditing(true)
// Sync values từ ev mới (tránh stale state khi switch giữa 2 phiếu)
setTenGoiThau(ev.tenGoiThau)
setDiaDiem(ev.diaDiem ?? '')
setMoTa(ev.moTa ?? '')
setPaymentTerms(ev.paymentTerms ?? '')
}
}, [autoEdit, canEdit, ev.id, ev.tenGoiThau, ev.diaDiem, ev.moTa, ev.paymentTerms])
const dirty = tenGoiThau !== ev.tenGoiThau
|| diaDiem !== (ev.diaDiem ?? '')
|| moTa !== (ev.moTa ?? '')
|| paymentTerms !== (ev.paymentTerms ?? '')
const save = useMutation({
mutationFn: async () => {
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau,
diaDiem: diaDiem || null,
moTa: moTa || null,
paymentTerms: paymentTerms || null,
// S61 — module Budget cũ XÓA HẲN; PE giữ 2 ô ngân sách mới (echo lại
// giá trị hiện tại để PUT update không xóa nhầm — drafter sửa qua bảng
// TỔNG HỢP NGÂN SÁCH / PATCH budget-adjust).
budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount,
})
},
onSuccess: () => {
toast.success('Đã cập nhật thông tin')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
setEditing(false)
},
onError: e => toast.error(getErrorMessage(e)),
})
function reset() {
setTenGoiThau(ev.tenGoiThau)
setDiaDiem(ev.diaDiem ?? '')
setMoTa(ev.moTa ?? '')
setPaymentTerms(ev.paymentTerms ?? '')
}
if (!editing) {
return (
{canEdit && (
setEditing(true)}
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-slate-500 hover:bg-slate-100 hover:text-brand-600"
title="Sửa thông tin gói thầu"
>
Sửa
)}
{/* S57bis — Hạng mục công việc (WorkItem master). Phiếu cũ null → "—". */}
{(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
{ev.diaDiem &&
Địa điểm: {ev.diaDiem}
}
{ev.moTa &&
Mô tả: {ev.moTa}
}
{ev.paymentTerms &&
Điều khoản TT: {ev.paymentTerms}
}
)}
)
}
// Editing mode
return (
{ reset(); setEditing(false) }}
className="h-7 px-3 text-xs"
>
Hủy
save.mutate()}
disabled={!dirty || !tenGoiThau || save.isPending}
className="h-7 px-3 text-xs"
>
{save.isPending ? 'Đang lưu…' : 'Lưu'}
)
}
// ===== a. NCC / TP được chọn — TÓM TẮT read-only [multi-NCC per hạng mục] =====
// Winner giờ CHỌN THEO TỪNG HẠNG MỤC ở bảng NCC (Mục 2) — nguồn sự thật = quote.isSelected
// mỗi cặp (hạng mục × NCC). Picker phiếu-wide cũ MẤT NGHĨA → hạ-cấp thành ô TÓM TẮT: mỗi
// đơn vị trúng + các hạng mục nó thắng (derive ev.details[].quotes IsSelected). Tổng =
// ev.winnerQuoteTotal (BE = SUM mọi quote IsSelected).
function NccSelectorRow({ ev }: { ev: PeDetailBundle }) {
const winners = ev.suppliers.filter(s => s.isWinner)
// Các hạng mục mà supplier-row `rowId` thắng (có ≥1 quote IsSelected trong hạng mục đó).
const wonItems = (rowId: string) =>
ev.details
.filter(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === rowId && q.isSelected))
.map(d => d.groupCode || d.noiDung)
return (
0 ? (
{winners.map(s => {
const items = wonItems(s.id)
return (
✓ {s.supplierName}
{items.length > 0 && (
— thắng hạng mục: {items.join(', ')}
)}
)
})}
Tổng chi phí đã chọn ({winners.length} đơn vị):
{fmtMoney(ev.winnerQuoteTotal)} đ
Chọn NCC trúng thầu theo từng hạng mục ở Mục 2 (Hạng mục + Báo giá NCC).
) : (
— (chưa chọn — chọn theo từng hạng mục ở Mục 2)
)}
/>
)
}
// ===== b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt) =====
// Module Budget cũ XÓA HẲN → ngân sách gói thầu per (Dự án × Hạng mục) compute
// BE trả `ev.budgetSummary`. 2 block:
// A. NGÂN SÁCH (gói thầu): full / ban hành lần đầu (CCM) / hiệu chỉnh (CCM) /
// dự trù PRO + ghi chú (PRO) — editable theo capability flag canEditCcm/canEditPro.
// B. THỰC HIỆN: 9 dòng công thức Excel — drafter nhập row3 (NS kỳ này) + row8
// (giá trị thực hiện dự kiến còn lại) qua PATCH /budget-adjust.
// budgetSummary=null → phiếu cũ chưa gắn Hạng mục → banner nhắc gắn.
// fmtVnd: "1.234.567 đ". fmtPct: 1 chữ số thập phân, guard chia-0 (denom<=0 → null).
const fmtVnd = (v: number) => `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtVndSigned = (v: number) =>
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtPct = (num: number, denom: number): string | null =>
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
// [C4b anh Kiệt FDC] Dòng "So sánh" = 0 (đề xuất ĐÚNG BẰNG ngân sách) → chữ thay "0 đ"
// cho đỡ khó hiểu. base>0 (có ngân sách thật) → "Bằng ngân sách"; base<=0 (phiếu trống) → "—".
const fmtCompareValue = (v: number, base: number): React.ReactNode =>
v === 0
? {base > 0 ? 'Bằng ngân sách' : '—'}
: {fmtVndSigned(v)}
// Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
function VndInlineEdit({
initial, allowNegative = false, onSave, saving, label, onLiveChange,
}: {
initial: number | null
allowNegative?: boolean
onSave: (v: number | null) => void
saving: boolean
label?: string
/** [C4a anh Kiệt FDC] báo giá trị ĐANG GÕ lên cha mỗi keystroke → live-recompute. */
onLiveChange?: (v: number | null) => void
}) {
const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((initial ?? 0) < 0)
const valueOf = (raw: string, isNeg: boolean): number | null => {
const n = parseVnd(raw)
if (n === 0 && raw.trim() === '') return null
return allowNegative && isNeg ? -n : n
}
const parse = (): number | null => valueOf(text, neg)
const dirty = parse() !== initial
return (
{allowNegative && (
{ const nv = !neg; setNeg(nv); onLiveChange?.(valueOf(text, nv)) }}
className={cn(
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
)}
title="Đảo dấu âm/dương"
>
{neg ? '−' : '+'}
)}
{
setText(formatVndInput(parseVnd(e.target.value)))
onLiveChange?.(valueOf(e.target.value, neg))
}}
placeholder="0"
aria-label={label}
className="h-7 pr-6 font-mono text-right text-[13px]"
/>
đ
onSave(parse())}
disabled={!dirty || saving}
className="h-7 px-2 text-[11px]"
>
{saving ? '…' : 'Lưu'}
)
}
// [Mig 59] BudgetRow (1-cột flex) GỠ — Section B nay là 3 cột (DỰ ÁN|PRO|CCM)
// mirror Section A. Dòng/ô dùng BudgetSharedNumCell / BudgetColValue / BudgetColCompareCell.
// Block tiêu đề (A / B)
function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
// [S76] Ô tiền compact cho ma trận 3 cột — editable (input + nút Lưu) hoặc display.
// allowNegative cho "hiệu chỉnh tăng giảm" (nút ± đảo dấu). onSave nhận number|null.
function BudgetCell({ value, editable, allowNegative = false, saving, onSave }: {
value: number | null
editable: boolean
allowNegative?: boolean
saving: boolean
onSave: (v: number | null) => void
}) {
const [text, setText] = useState(value != null ? Math.abs(value).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((value ?? 0) < 0)
useEffect(() => {
setText(value != null ? Math.abs(value).toLocaleString('vi-VN') : '')
setNeg((value ?? 0) < 0)
}, [value])
if (!editable) {
return value != null
? {fmtVndSigned(value)}
: —
}
const parse = (): number | null => {
const n = parseVnd(text)
if (n === 0 && text.trim() === '') return null
return allowNegative && neg ? -n : n
}
const dirty = parse() !== value
return (
)
}
// [S76] Ô ghi chú phòng (Ghi chú từ PRO / từ CCM) — Textarea editable hoặc text display.
// Đặt trong của bảng ngân sách (không bọc row riêng).
function BudgetNoteCell({ editable, value, setValue, savedValue, saving, onSave }: {
editable: boolean
value: string
setValue: (v: string) => void
savedValue: string | null
saving: boolean
onSave: () => void
}) {
if (!editable) {
return (
{savedValue || — }
)
}
return (
)
}
// [Mig 59] Ô số CHUNG mọi cột (Section B THỰC HIỆN) — 1 giá trị span hết 3 cột Dự án/PRO/CCM
// (dòng 1/2/6 không tách cột). value null → "Chưa chọn".
function BudgetSharedNumCell({ value, colSpan, sub }: { value: number | null; colSpan: number; sub?: ReactNode }) {
return (
{value == null
? Chưa chọn
: {fmtVnd(value)} }
{sub}
)
}
// [Mig 59] Ô giá trị 1 cột (PRO/CCM) Section B — display read-only. value null → "—".
// signed → fmtVndSigned (âm trong ngoặc). pct → dòng % phụ dưới. asCell bọc sẵn
// (cho dòng 5/6/7/8 ccm); KHÔNG asCell → chỉ nội dung (nhúng vào editable dòng 3).
function BudgetColValue({ value, pct, signed = false, asCell = false, sub }: {
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean; sub?: ReactNode
}) {
const body = value == null
? —
: (
<>
{signed ? fmtVndSigned(value) : fmtVnd(value)}
{pct && {pct}
}
>
)
return asCell
? {body}{sub}
: <>{body}{sub}>
}
// [Mig 59] Ô "So sánh" 1 cột (PRO/CCM) Section B — = 0 → text "Bằng ngân sách" (reuse
// fmtCompareValue); âm → đỏ. pct dòng phụ. Luôn bọc .
function BudgetColCompareCell({ value, base, pct }: { value: number; base: number; pct: string | null }) {
return (
{fmtCompareValue(value, base)}
{pct && {pct}
}
)
}
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient()
const bs = ev.budgetSummary
// [S76] Khoá nút Lưu trong lúc pe-detail đang refetch (sau mỗi save) — đóng cửa-sổ
// stale-echo: tránh lưu 1 ô khi bs (server snapshot) chưa cập nhật → đè field anh-em
// (vd lưu PRO ban hành xong, lưu PRO hiệu chỉnh ngay sẽ echo bs.proInitial CŨ).
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
// Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
// khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
const drafterEditable = !readOnly && isEditablePhase(ev.phase)
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
}
// PUT /budget/pro — chỉ khi canEditPro. [S76] proInitial + proAdjust + proNote.
const proMut = useMutation({
mutationFn: async (body: { proInitialAmount: number | null; proAdjustmentAmount: number | null; proNote: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách PRO'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// PUT /budget/ccm — chỉ khi canEditCcm. initialAmount + adjustmentAmount.
const ccmMut = useMutation({
mutationFn: async (body: { initialAmount: number | null; adjustmentAmount: number | null; ccmNote: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/ccm`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách ban hành'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// PATCH /budget-adjust — ABSOLUTE-SET: BE set thẳng CẢ 2 field (thiếu field =
// null = CLEAR). Mọi call-site PHẢI gửi đủ cặp {budgetPeriodAmount,
// expectedRemainingAmount} (field không đổi → echo giá trị hiện tại từ ev).
const adjustMut = useMutation({
mutationFn: async (body: { budgetPeriodAmount?: number | null; expectedRemainingAmount?: number | null }) =>
api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, body),
onSuccess: () => { toast.success('Đã lưu'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// [Mig 59 anh Kiệt FDC / CCM] PATCH /budget/ccm-period — "NS kỳ này" cột CCM (Section B
// tách 3 cột). Role-gate Admin|CostControl (BE Forbidden nếu khác). Absolute-set (null=
// clear). Gate FE qua bs.canEditCcm (BE-computed capability, mirror ô NS Block A).
const ccmPeriodMut = useMutation({
mutationFn: async (body: { ccmBudgetPeriodAmount: number | null }) =>
api.patch(`/purchase-evaluations/${ev.id}/budget/ccm-period`, body),
onSuccess: () => { toast.success('Đã lưu NS kỳ này (CCM)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// proNote inline-edit state (Textarea — không dùng VndInlineEdit)
const [proNoteText, setProNoteText] = useState(bs?.proNote ?? '')
useEffect(() => { setProNoteText(bs?.proNote ?? '') }, [bs?.proNote])
// ccmNote inline-edit state (mirror proNoteText) — [Mig anh Kiệt FDC]
const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [bs?.ccmNote])
// [C4a anh Kiệt FDC] Live-recompute: giữ giá trị ĐANG GÕ của ô 3 (NS kỳ này) + ô 8 (giá
// trị TH dự kiến còn lại) ở state cục bộ → dòng 5/6/7/9 + So sánh + % nhảy NGAY khi gõ
// (chưa cần bấm Lưu). Sync lại từ server (ev.*) sau mỗi save/refetch.
// [S85 D3 anh Kiệt] draftRow3 default ← "Ngân sách Ban hành lần đầu" (bs.proInitialAmount)
// khi chưa nhập tay budgetPeriodAmount → row3 "tự nhảy". bs có thể null (guard !bs dưới) → optional.
const [draftRow3, setDraftRow3] = useState(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null)
const [draftRow8, setDraftRow8] = useState(ev.expectedRemainingAmount)
useEffect(() => { setDraftRow3(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) }, [ev.budgetPeriodAmount, bs?.proInitialAmount])
useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount])
// [Mig 59] Live-recompute cột CCM: giữ "NS kỳ này (CCM)" đang gõ → row5/7/9 + So sánh
// cột CCM nhảy ngay. Default ← bs.initialAmount (NS Ban hành lần đầu CCM) khi chưa nhập.
const [draftCcmRow3, setDraftCcmRow3] = useState(ev.ccmBudgetPeriodAmount ?? bs?.initialAmount ?? null)
useEffect(() => { setDraftCcmRow3(ev.ccmBudgetPeriodAmount ?? bs?.initialAmount ?? null) }, [ev.ccmBudgetPeriodAmount, bs?.initialAmount])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) {
return (
Phiếu chưa gắn Hạng mục công việc — gắn Hạng mục để dùng ngân sách gói thầu.
)
}
// ===== Số liệu Excel =====
// [Mig 59] Bỏ `full` đơn-cột (bs.fullAmount cũ) — Section A+B nay tách 3 cột, mỗi cột
// dùng full RIÊNG = ban hành + hiệu chỉnh của cột đó (proFull / ccmFull dưới).
const proFull = (bs.proInitialAmount ?? 0) + (bs.proAdjustmentAmount ?? 0)
const ccmFull = (bs.initialAmount ?? 0) + (bs.adjustmentAmount ?? 0)
// Cột "có dữ liệu" = đã nhập ban hành HOẶC hiệu chỉnh → hiện full (kể cả 0/âm); else "—".
const proHasData = bs.proInitialAmount != null || bs.proAdjustmentAmount != null
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
// ===== Số THỰC-TẾ dùng CHUNG mọi cột (KHÔNG per-cột) =====
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
// [S134 anh Kiệt FDC] Lũy kế TẠM TÍNH — phần góp từ phiếu cùng gói thầu CHƯA duyệt
// (?? guard data cũ chưa có field). Chỉ hiện khi phiếu CHƯA freeze (số DaDuyet là
// snapshot bất biến S133 — KHÔNG thêm tạm-tính). N8: row1/5 dùng pendingSubmitted*,
// row2/6 dùng pendingSelected*.
const pendingSubmittedTotal = bs.pendingSubmittedTotal ?? 0
const pendingSubmittedCount = bs.pendingSubmittedCount ?? 0
const pendingSelectedTotal = bs.pendingSelectedTotal ?? 0
const pendingSelectedCount = bs.pendingSelectedCount ?? 0
const pendingPriorPes = bs.pendingPriorPes ?? []
const showPending = !bs.budgetFrozen && (pendingSubmittedCount > 0 || pendingSelectedCount > 0 || pendingPriorPes.length > 0)
// Sub-dòng amber "Tạm tính" — LUÔN kèm chữ "Tạm tính", KHÔNG bao giờ thay số chính xác.
const pendingSub = (base: number, delta: number, count: number, withDetail: boolean) => (
Tạm tính: {fmtVnd(base + delta)}
{withDetail && ` (+${fmtVnd(delta)} từ ${count} phiếu chưa duyệt)`}
)
// [Mig 59] Mỗi cột tính ĐỘC-LẬP theo "NS kỳ này" + "full" của CỘT đó. Helper trả về
// bộ row3/5/7/8/9 + 3 so-sánh cho 1 cột. periodAmount = NS kỳ này (live); colFull =
// full cột; remaining = giá trị TH dự kiến còn lại (null → default row7).
const colCalc = (periodAmount: number | null, colFull: number, remaining: number | null) => {
const r3 = periodAmount ?? 0
const r5 = row1 + r3 // Lũy kế NS đã dùng (= 1 + 3 cột)
const r7 = colFull - r5 // NS còn lại (= full cột − 5 cột)
const r8 = remaining ?? r7 // Giá trị TH dự kiến còn lại (default = 7 cột)
const r9 = row4 + r8 // Giá trị tổng TH dự kiến (= 4 + 8 cột)
return {
r3, r5, r7, r8, r9,
cmpPeriod: r3 - row4, // So với NS kỳ này (= 3 cột − 4)
cmp56: r5 - row6, // So với NS (= 5 cột − 6)
cmpFull: colFull - r9, // So với NS full (= full cột − 9 cột)
}
}
// Cột PRO: kỳ này = budgetPeriodAmount (live draftRow3) · full = proFull · row8 editable
// (draftRow8 ↔ expectedRemainingAmount, drafter). Cột CCM: kỳ này = ccmBudgetPeriodAmount
// (live draftCcmRow3) · full = ccmFull · row8 = default (chưa có field CCM-remaining riêng).
const pro = colCalc(draftRow3, proFull, draftRow8)
const ccm = colCalc(draftCcmRow3, ccmFull, null)
// Cờ tô màu cảnh báo (cảnh báo MỀM, dựa cột PRO — luồng drafter chính). Hiện banner khi
// PRO HOẶC CCM vượt (đề xuất > NS kỳ này, hoặc tổng TH > NS full).
const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
const remainingOver = draftRow8 != null && draftRow8 > pro.r7
const anyOver = pro.cmpPeriod < 0 || pro.cmpFull < 0 || ccm.cmpPeriod < 0 || ccm.cmpFull < 0
return (
Tổng hợp ngân sách trình ký
{bs.budgetFrozen && (
🔒 Ngân sách chốt tại thời điểm duyệt
)}
{/* [S62] Cảnh báo MỀM "vượt ngân sách" (anh Kiệt FDC) — KHÔNG chặn lưu, chỉ báo.
Hiện khi đề xuất kỳ này > NS kỳ này (cmpPeriod<0) hoặc tổng thực hiện > NS full
(cmpFull<0) ở BẤT KỲ cột PRO/CCM. Số dư còn lại âm vẫn lưu + gửi duyệt được. */}
{anyOver && (
⚠️
Vượt ngân sách — giá trị đề xuất NCC đang cao hơn ngân sách của gói thầu.
Phiếu vẫn lưu & gửi duyệt được , vui lòng kiểm tra lại số liệu trước khi trình.
)}
{/* ===== Block A — NGÂN SÁCH gói thầu: BẢNG LƯỚI 3 cột (DỰ ÁN | PRO | CCM) =====
[S76 anh Kiệt FDC]
viền ô đầy đủ giống file Excel. Mỗi phòng nhập+điều
chỉnh cột CHÍNH mình (role-gate): PRO→cột PRO (canEditPro) · CCM→cột CCM
(canEditCcm) · DỰ ÁN hiển-thị-only (—, sau có người dự án nhập). Full mỗi cột
= ban hành + hiệu chỉnh. */}
A. Ngân sách (gói thầu / gói vật tư)
Khoản mục
Dự án
PRO
CCM
{/* Ngân sách full (= ban hành + hiệu chỉnh) mỗi cột */}
Ngân sách (full gói thầu / gói vật tư)
—
{proHasData ? fmtVnd(proFull) : — }
{ccmHasData ? fmtVnd(ccmFull) : — }
{/* Ban hành lần đầu */}
– Ngân sách Ban hành lần đầu
—
proMut.mutate({ proInitialAmount: v, proAdjustmentAmount: bs.proAdjustmentAmount, proNote: bs.proNote })}
/>
ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount, ccmNote: bs.ccmNote })}
/>
{/* V0 / hiệu chỉnh tăng giảm (cho phép ÂM) */}
– Ngân sách V0 / hiệu chỉnh tăng giảm
—
proMut.mutate({ proInitialAmount: bs.proInitialAmount, proAdjustmentAmount: v, proNote: bs.proNote })}
/>
ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v, ccmNote: bs.ccmNote })}
/>
{/* Ghi chú từ PRO (span 3 cột giá trị) */}
Ghi chú từ PRO
proMut.mutate({ proInitialAmount: bs.proInitialAmount, proAdjustmentAmount: bs.proAdjustmentAmount, proNote: proNoteText || null })}
/>
{/* Ghi chú từ CCM (span 3 cột giá trị) */}
Ghi chú từ CCM
ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: bs.adjustmentAmount, ccmNote: ccmNoteText || null })}
/>
{/* ===== Block B — THỰC HIỆN: BẢNG LƯỚI 3 cột (DỰ ÁN | PRO | CCM) =====
[Mig 59 anh Kiệt FDC / CCM] Mirror style Block A. Mỗi cột tính ĐỘC-LẬP theo "NS kỳ
này" + "full" của cột đó (pro/ccm = colCalc). Số thực-tế (1/2/4/6) dùng CHUNG mọi
cột. "NS kỳ này": PRO editable drafter (VndInlineEdit + /budget-adjust hiện có) ·
CCM editable canEditCcm (VndInlineEdit + PATCH /budget/ccm-period) · DỰ ÁN "—". */}
B. Thực hiện
Khoản mục
Dự án
PRO
CCM
{/* 1 — Ngân sách trình duyệt trước (CHUNG) */}
1. Ngân sách trình duyệt trước
0 ? pendingSub(row1, pendingSubmittedTotal, pendingSubmittedCount, true) : undefined} />
{/* 2 — Kỳ trước đã chọn thầu (CHUNG) */}
2. Kỳ trước đã chọn thầu
0 ? pendingSub(row2, pendingSelectedTotal, pendingSelectedCount, true) : undefined} />
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
3. Ngân sách - kỳ này
% so với NS full của cột
—
{/* PRO — drafter giữ luồng /budget-adjust hiện có */}
{drafterEditable ? (
adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/>
) : (
)}
{drafterEditable && {fmtPct(pro.r3, proFull) ?? ''}
}
{/* CCM — endpoint MỚI /budget/ccm-period, gate canEditCcm */}
{bs.canEditCcm ? (
ccmPeriodMut.mutate({ ccmBudgetPeriodAmount: v })}
/>
) : (
)}
{bs.canEditCcm && {fmtPct(ccm.r3, ccmFull) ?? ''}
}
{/* 4 — Đề xuất kỳ này (CHUNG): NCC + giá trị + so sánh per-cột */}
4. Đề xuất kỳ này — Tên thầu phụ / NCC
{ev.suppliers.some(s => s.isWinner)
? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ')
: — (chưa chọn) }
– Giá trị kỳ này (CHUNG)
{proposalOver
? {fmtVnd(row4)}
: fmtVnd(row4)}
– So sánh với ngân sách kỳ này
= 3 − 4
—
{/* 5 — Lũy kế ngân sách đã sử dụng (PER-cột = 1 + 3) */}
5. Lũy kế ngân sách đã sử dụng
= 1 + 3
—
0 ? pendingSub(pro.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} />
0 ? pendingSub(ccm.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} />
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
6. Lũy kế thực hiện
= 2 + 4
0 ? pendingSub(row6, pendingSelectedTotal, pendingSelectedCount, false) : undefined} />
– So với NS
= 5 − 6
—
{/* 7 — Ngân sách còn lại (PER-cột = full − 5) + % /full cột */}
7. Ngân sách còn lại
= NS full − 5
—
{/* 8 — Giá trị thực hiện dự kiến còn lại — PRO editable (drafter); CCM default */}
8. Giá trị thực hiện dự kiến còn lại
mặc định = 7 nếu chưa nhập
—
{drafterEditable ? (
adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/>
) : (
{fmtVndSigned(pro.r8)}
)}
{/* 9 — Giá trị tổng thực hiện dự kiến (PER-cột = 4 + 8) — brand đậm */}
9. Giá trị tổng thực hiện dự kiến
= 4 + 8
—
{fmtVndSigned(pro.r9)}
{fmtVndSigned(ccm.r9)}
– So sánh với Ngân sách full
= NS full − 9
—
{/* [S134] Danh sách phiếu trước cùng gói thầu CHƯA duyệt (informational — N9:
PLAIN TEXT + badge, KHÔNG link vì click phiếu Nháp/chưa-duyệt → 403 S89). */}
{showPending && pendingPriorPes.length > 0 && (
Phiếu trước cùng gói chưa duyệt:
{pendingPriorPes.map((p, i) => (
{i > 0 && ', '}
{p.maPhieu ?? '(chưa có mã)'} {' '}
{PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`}
))}
)}
)
}
// [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất tại khối "c. Giá chào thầu".
// PRO nhập dải Min/Max (PUT /suggested-price/pro {minPrice,maxPrice}); CCM nhập 1
// giá (PUT /suggested-price/ccm {ccmPrice}). Role-gate qua canEditPro/CcmSuggestedPrice
// (BE-computed capability — mirror budget PRO/CCM, KHÔNG ràng phase). Read-only khi
// !canEdit → hiện text giá. Khi DaDuyet + approvedPriceAmount → dòng "Giá chốt duyệt".
const APPROVED_PRICE_SOURCE_LABEL: Record = {
Ncc: 'Giá NCC',
ProMin: 'PRO Min',
ProMax: 'PRO Max',
ProMinMax: 'PRO Min–Max',
Ccm: 'CCM',
}
function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) {
const qc = useQueryClient()
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
}
// PRO Min/Max + note — ABSOLUTE SET cả 3 field (mirror S74 CcmNote: field không đổi
// echo giá trị hiện tại để không bị clear). Lưu giá → echo note hiện tại; lưu note →
// echo min/max hiện tại.
const proPriceMut = useMutation({
mutationFn: async (body: { minPrice: number | null; maxPrice: number | null; note: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/suggested-price/pro`, body),
onSuccess: () => { toast.success('Đã lưu giá đề xuất (PRO)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
const ccmPriceMut = useMutation({
mutationFn: async (body: { ccmPrice: number | null; note: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/suggested-price/ccm`, body),
onSuccess: () => { toast.success('Đã lưu giá đề xuất (CCM)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
// [S89 anh Kiệt FDC] Giá đề xuất PRO/CCM editable theo ROLE-capability (canEditPro/CcmSuggestedPrice)
// — nhập ở mọi view khi phiếu CÒN soạn/trả-lại. [S96 2026-07-01 — anh Kiệt "duyệt là fix"] THÊM
// gắn phase: phiếu ĐÃ GỬI DUYỆT / ĐÃ DUYỆT / TỪ CHỐI → KHÓA (read-only) — không sửa giá ngầm sau
// khi cấp duyệt đã xem. Đồng bộ BE guard (UpdatePeSuggestedPricePro/Ccm phase-gate). Admin sửa-sai
// qua API (BE cho admin override; FE ẩn cho gọn — hiếm khi cần).
const editablePhase = isEditablePhase(ev.phase) // DangSoanThao || TraLai
const canEditPro = ev.canEditProSuggestedPrice && editablePhase
const canEditCcm = ev.canEditCcmSuggestedPrice && editablePhase
// Ghi chú PRO/CCM inline-edit state (Textarea). Echo cùng body absolute-set khi lưu giá.
const [proNoteText, setProNoteText] = useState(ev.proSuggestedPriceNote ?? '')
useEffect(() => { setProNoteText(ev.proSuggestedPriceNote ?? '') }, [ev.proSuggestedPriceNote])
const [ccmNoteText, setCcmNoteText] = useState(ev.ccmSuggestedPriceNote ?? '')
useEffect(() => { setCcmNoteText(ev.ccmSuggestedPriceNote ?? '') }, [ev.ccmSuggestedPriceNote])
// [gotcha #70] khoá nút Lưu tới khi pe-detail refetch land — lưu giá xong lưu
// ghi chú ngay (hoặc ngược lại) sẽ echo ev.* CŨ từ snapshot → mất dữ liệu.
// Mirror peFetching của bảng ngân sách (S76).
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
const hasAnyValue = ev.proSuggestedMinPrice != null
|| ev.proSuggestedMaxPrice != null
|| ev.ccmSuggestedPrice != null
|| !!ev.proSuggestedPriceNote
|| !!ev.ccmSuggestedPriceNote
const approved = ev.phase === PurchaseEvaluationPhase.DaDuyet && ev.approvedPriceAmount != null
// Ẩn hoàn toàn khi không edit được + chưa có giá nào + chưa chốt → tránh khối rỗng.
if (!canEditPro && !canEditCcm && !hasAnyValue && !approved) return null
return (
2 – 3. Giá đề xuất từ PRO / CCM (ngoài giá chào thầu)
{/* PRO — Giá Min / Giá Max */}
Giá đề xuất (PRO)
Min
{canEditPro ? (
proPriceMut.mutate({ minPrice: v, maxPrice: ev.proSuggestedMaxPrice, note: ev.proSuggestedPriceNote })}
/>
) : (
{ev.proSuggestedMinPrice != null ? fmtVnd(ev.proSuggestedMinPrice) : — }
)}
Max
{canEditPro ? (
proPriceMut.mutate({ minPrice: ev.proSuggestedMinPrice, maxPrice: v, note: ev.proSuggestedPriceNote })}
/>
) : (
{ev.proSuggestedMaxPrice != null ? fmtVnd(ev.proSuggestedMaxPrice) : — }
)}
{/* Ghi chú PRO — vì sao chọn Min/Max. Editable khi canEditPro (Textarea + nút Lưu),
read-only hiện text khi có note. Lưu qua proPriceMut (echo min/max hiện tại). */}
{(canEditPro || ev.proSuggestedPriceNote) && (
Ghi chú (PRO)
{canEditPro ? (
) : (
{ev.proSuggestedPriceNote}
)}
)}
{/* CCM — 1 giá */}
Giá đề xuất (CCM)
{canEditCcm ? (
ccmPriceMut.mutate({ ccmPrice: v, note: ev.ccmSuggestedPriceNote })}
/>
) : (
{ev.ccmSuggestedPrice != null ? fmtVnd(ev.ccmSuggestedPrice) : — }
)}
{/* Ghi chú CCM — vì sao 1 giá. Editable khi canEditCcm, read-only hiện text khi có note.
Lưu qua ccmPriceMut (echo ccmPrice hiện tại). */}
{(canEditCcm || ev.ccmSuggestedPriceNote) && (
Ghi chú (CCM)
{canEditCcm ? (
) : (
{ev.ccmSuggestedPriceNote}
)}
)}
{/* Giá CHỐT duyệt — chỉ khi DaDuyet + approvedPriceAmount != null. */}
{approved && (
Giá chốt duyệt
{ev.approvedPriceMaxAmount != null
? `${fmtVnd(ev.approvedPriceAmount!)} – ${fmtVnd(ev.approvedPriceMaxAmount)}`
: fmtVnd(ev.approvedPriceAmount!)}
{ev.approvedPriceSource && (
{APPROVED_PRICE_SOURCE_LABEL[ev.approvedPriceSource] ?? ev.approvedPriceSource}
)}
)}
)
}
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.suppliers.some(s => s.isWinner)
const [createOpen, setCreateOpen] = useState(false)
// [anh Kiệt FDC S87+] c/d/e (Giá chào thầu + Đề xuất PRO/CCM + Bảng so sánh + Link hồ
// sơ) + hiển-thị winner ĐÃ TÁCH sang khối nổi bật "4. Thông tin chọn thầu"
// (ThongTinChonThauSection). Section 3 này GIỮ: a. ô CHỌN winner + b. bảng ngân sách + tạo HĐ.
return (
{/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
{/* [anh Kiệt FDC S87+] c/d/e + hiển-thị winner ĐÃ chuyển sang khối nổi bật
"4. Thông tin chọn thầu" (ThongTinChonThauSection — render ở parent, sau Section 3). */}
{ev.paymentTerms && (
{ev.paymentTerms}} />
)}
{ev.contractId && (
✓ Xem HĐ}
/>
)}
{canCreateContract && (
✓ Phiếu đã duyệt. Bấm để tạo HĐ mới kế thừa NCC + hạng mục.
setCreateOpen(true)} className="gap-1.5 text-xs">
Tạo HĐ từ phiếu
)}
{createOpen && setCreateOpen(false)} />}
)
}
// ===== Section 4 — Thông tin chọn thầu (anh Kiệt FDC S87+) =====
// Tách c/d/e khỏi Section 3 thành khối NỔI BẬT riêng (header xanh #1F7DC1 giống bảng
// ngân sách "giá trị thực hiện"). Đánh số 1→5: 1.Tên NCC/TP+giá trị · 2-3.Đề xuất
// PRO/CCM · 4.Bảng so sánh giá · 5.Link hồ sơ. Tên đơn vị "mang từ trên xuống" — Section 3
// giữ ô CHỌN (NccSelectorRow), khối này HIỂN THỊ kết quả (multi-winner → tên cách dấu phẩy).
function ThongTinChonThauSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const winners = ev.suppliers.filter(s => s.isWinner)
const hasWinner = winners.length > 0
const giaChaoThau = computeGiaChaoThau(ev)
const banSoSanhAttachments = ev.attachments.filter(
a => a.purchaseEvaluationSupplierId === null
&& a.purpose !== PeAttachmentPurpose.ApprovalAttachment,
)
// [Fix UAT — phiếu bị Trả lại: không xóa/tải lại được file "Bảng so sánh"]
// Nút xóa/tải file so sánh phải theo NGƯỜI-SOẠN (hoặc Admin) + TRẠNG-THÁI-được-sửa,
// KHÔNG theo readOnly của MÀN HÌNH: màn "Danh sách" mở phiếu readOnly=true nên ẩn
// nút ở MỌI phase — kể cả Trả lại, vốn nằm trong isEditablePhase. Chỉ tách RIÊNG
// khối đính kèm này; HoSoLink + winner bên dưới vẫn theo readOnly màn hình (không
// mở nhầm). Chỉ áp cho "Bảng so sánh" (GeneralAttachmentsSection) — file per-NCC
// (SupplierAttachmentsCell) giữ nguyên carve-out approverEditMode (Mig 28 F3).
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
const attachEditable = (isAdmin || isDrafter) && isEditablePhase(ev.phase)
return (
Thông tin chọn thầu
{/* 1. Tên NCC/TP + Giá trị (chào thầu) — kéo từ trên xuống, nổi bật */}
1
Tên NCC / TP được chọn
{hasWinner
?
{winners.map(s => s.supplierName).join(', ')}
:
— (chọn đơn vị ở mục 3 trước)
}
Giá trị (chào thầu):
{!hasWinner
? —
: giaChaoThau === 0
? — (chưa nhập báo giá)
: {giaChaoThau!.toLocaleString('vi-VN')} đ }
{/* 2 – 3. Giá đề xuất PRO / CCM (ngoài giá chào thầu) */}
{/* 4. Bảng so sánh giá */}
{/* 5. Link hồ sơ */}
)
}
// e. Link hồ sơ — 1 cột HoSoLink (string? nullable) trỏ thư mục hồ sơ NAS.
// Read-only: thẻ bấm-mở. Editable (phiếu DangSoanThao/TraLai + !readOnly):
// Input dán URL + nút Lưu. Save = PUT /purchase-evaluations/:id echo field bắt
// buộc (tenGoiThau + 2 ô ngân sách) như InfoTab.save để không xóa nhầm data.
function HoSoLinkRow({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
const [hoSoLink, setHoSoLink] = useState(ev.hoSoLink ?? '')
useEffect(() => { setHoSoLink(ev.hoSoLink ?? '') }, [ev.id, ev.hoSoLink])
const dirty = hoSoLink !== (ev.hoSoLink ?? '')
const save = useMutation({
mutationFn: async () => {
await api.put(`/purchase-evaluations/${ev.id}`, {
id: ev.id,
tenGoiThau: ev.tenGoiThau,
diaDiem: ev.diaDiem,
moTa: ev.moTa,
paymentTerms: ev.paymentTerms,
budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount,
// BE UpdatePeDraftHandler null-safe: "" = clear, non-empty = set,
// omit/null = preserve. Gửi raw trimmed (="" khi xóa) để CLEAR đúng,
// KHÔNG gửi null (null = giữ nguyên → không xóa được link cũ).
hoSoLink: hoSoLink.trim(),
})
},
onSuccess: () => {
toast.success('Đã lưu link hồ sơ')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
5. Link hồ sơ
{canEdit ? (
setHoSoLink(e.target.value)}
placeholder="Dán link thư mục hồ sơ trên NAS..."
className="text-sm"
/>
save.mutate()}
disabled={!dirty || save.isPending}
className="h-9 shrink-0 px-3 text-xs"
>
{save.isPending ? 'Đang lưu…' : 'Lưu'}
) : ev.hoSoLink ? (
/^https?:\/\//i.test(ev.hoSoLink.trim()) ? (
// Link web (http/https — vd SharePoint) → bấm mở thẳng tab mới.
{ev.hoSoLink}
) : (
// Đường dẫn ổ cứng/ổ mạng (O:\…, \\server) → render link file:// để BẤM-THỬ
// mở File Explorer (chạy nếu máy/trình duyệt cho phép, vd máy domain map sẵn
// ổ mạng) + nút Copy dự phòng (default Chrome chặn https→file:// thì dùng Copy).
)
) : (
—
)}
)
}
// Đổi đường dẫn Windows → URL file:// (O:\DATA\x → file:///O:/DATA/x · \\srv\share → file://srv/share).
function toFileUrl(p: string): string {
const slashed = p.trim().replace(/\\/g, '/')
const url = slashed.startsWith('//') ? 'file:' + slashed : 'file:///' + slashed
return encodeURI(url)
}
// e.bis — Đường dẫn ổ cứng/ổ mạng (không phải http). Render LINK file:// để BẤM-THỬ
// mở File Explorer + nút Copy dự phòng. Bấm-mở chỉ chạy khi máy được cấu hình:
// Edge bật policy IntranetFileLinksEnabled (GPO 1 lần/domain, KHÔNG cài per-máy) +
// host trong Intranet Zone. Default Chrome/Edge CHẶN https→file:// (bấm no-op) →
// khi đó dùng nút Copy dán vào File Explorer (máy đã map ổ mạng là mở ngay).
function PathWithCopy({ path }: { path: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard.writeText(path)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
toast.error('Không copy được — vui lòng bôi đen đường dẫn rồi Ctrl+C.')
}
}
return (
{path}
{copied ? '✓ Đã copy' : 'Copy'}
)
}
// Form row: label cố định 176px (w-44) bên trái + value bên phải (giống spec).
function FormRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
{label}
{value}
)
}
function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) {
const navigate = useNavigate()
const [form, setForm] = useState({
contractType: 1,
tenHopDong: evaluation.tenGoiThau,
bypassProcurementAndCCM: false,
})
const mut = useMutation({
mutationFn: async () =>
// [Mig 58 — anh Kiệt FDC] Multi-winner: BE tạo 1 HĐ / mỗi đơn vị trúng (IsWinner)
// → trả LIST contractIds. ContractType người dùng chọn áp cho mọi HĐ.
api.post<{ contractIds: string[] }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form),
onSuccess: res => {
toast.success(`Đã tạo ${res.data.contractIds.length} hợp đồng từ phiếu.`)
navigate(`/contracts/${res.data.contractIds[0]}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
const typeOptions = [
[1, 'HĐ Thầu phụ'],
[2, 'HĐ Giao khoán'],
[3, 'HĐ Nhà cung cấp'],
[4, 'HĐ Dịch vụ'],
[5, 'HĐ Mua bán'],
[6, 'HĐ Nguyên tắc NCC'],
[7, 'HĐ Nguyên tắc DV'],
] as const
return (
Hủy
mut.mutate()} disabled={mut.isPending}>Tạo
>}
>
)
}
// Session 20 Chunk B: SuppliersTab function bỏ — NCC list giờ render nested
// trong HangMucCard (expand panel mỗi hạng mục). 2 dialog Add/Edit Supplier
// vẫn giữ vì HangMucCard call lại.
// Session 20 turn 8: Dialog thêm NCC mới — khi gọi từ HangMucCard (có detailId)
// thì input "Số tiền" hiển thị + sequential POST: tạo supplier → tạo quote
// cho hạng mục đó. detailId optional cho call site khác trong tương lai.
function AddSupplierDialog({ evaluationId, detailId, onClose }: {
evaluationId: string
detailId?: string
onClose: () => void
}) {
const qc = useQueryClient()
const suppliers = useQuery({
queryKey: ['all-suppliers'],
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000, published: true } })).data.items, // S113 R4: chỉ NCC đã công bố (ẩn nháp)
})
const [form, setForm] = useState({
supplierId: '',
displayName: '',
contactName: '',
contactEmail: '',
contactPhone: '',
paymentTermText: '',
note: '',
thanhTien: 0,
})
// [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG
// (KHÔNG nhét vào form) — nhớ ý-định-dấu khi ô còn trống/0. Toggle ghi giá-trị-CÓ-DẤU
// vào form.thanhTien NGAY (POST commit thẳng, không có nút "Lưu" riêng).
const [neg, setNeg] = useState(false)
const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : ''
const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
const hasError = !!(phoneError || emailError)
const showQuote = !!detailId
// S59 UAT "Không tự thêm dc tên NTP mới" — anh chốt mở POST /suppliers cho mọi
// user đăng nhập (Sửa/Xóa vẫn Admin/CatalogManager). Tạo xong auto-select vào phiếu.
const [showNew, setShowNew] = useState(false)
const [newSup, setNewSup] = useState({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
const createSup = useMutation({
mutationFn: async () => (await api.post<{ id: string }>('/suppliers', {
code: newSup.code.trim(),
name: newSup.name.trim(),
type: newSup.type,
phone: newSup.phone.trim() || null,
email: newSup.email.trim() || null,
})).data,
onSuccess: async created => {
toast.success('Đã tạo NCC mới vào danh mục.')
await qc.invalidateQueries({ queryKey: ['all-suppliers'] })
setForm(prev => ({ ...prev, supplierId: created.id, contactPhone: newSup.phone.trim(), contactEmail: newSup.email.trim() }))
setShowNew(false)
setNewSup({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
},
onError: e => toast.error(getErrorMessage(e)),
})
const mut = useMutation({
mutationFn: async () => {
// Step 1: tạo NCC tham gia (PE.Suppliers row)
const res = await api.post<{ id: string }>(`/purchase-evaluations/${evaluationId}/suppliers`, {
supplierId: form.supplierId,
displayName: form.displayName,
contactName: form.contactName,
contactEmail: form.contactEmail,
contactPhone: form.contactPhone,
paymentTermText: form.paymentTermText,
note: form.note,
})
const newSupplierRowId = res.data.id
// Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien !== 0).
// [S116] !== 0 (không > 0): giá ÂM tạo quote; rỗng/0 vẫn skip = "chưa báo giá".
if (detailId && form.thanhTien !== 0) {
await api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
purchaseEvaluationSupplierId: newSupplierRowId,
bgVat: 0,
chuaVat: 0,
thanhTien: form.thanhTien,
note: '',
isSelected: false,
})
}
},
onSuccess: () => {
toast.success(showQuote && form.thanhTien !== 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
onClose()
},
onError: e => toast.error(getErrorMessage(e)),
})
return (
Hủy
mut.mutate()} disabled={!form.supplierId || hasError || mut.isPending}>Thêm
>}
>
NCC (master)
{/* S59 UAT (3 ý): gõ-tìm (SearchableSelect) + sort A-Z theo mã + "+ NCC mới"
tạo nhanh vào danh mục dùng ngay. Auto-fill liên hệ từ master giữ nguyên. */}
({ value: s.id, label: `${s.code} — ${s.name}` }))
.sort((a, b) => a.label.localeCompare(b.label, 'vi', { numeric: true }))}
value={form.supplierId}
onChange={id => {
// Session 20 turn 10: auto-fill các field NCC từ master data sẵn có
// (contactPerson/phone/email/note). User vẫn override được sau đó.
const picked = suppliers.data?.find(s => s.id === id)
setForm(prev => ({
...prev,
supplierId: id,
contactName: picked?.contactPerson ?? '',
contactPhone: picked?.phone ?? '',
contactEmail: picked?.email ?? '',
note: picked?.note ?? '',
}))
}}
placeholder="-- Chọn NCC (gõ để lọc) --"
/>
setShowNew(v => !v)}
className="inline-flex h-8 shrink-0 items-center gap-1 whitespace-nowrap rounded-lg border border-dashed border-brand-300 px-2.5 text-[11px] font-medium text-brand-700 hover:bg-brand-50"
>
NCC mới
{form.supplierId &&
✓ Đã tự điền từ Master — bạn có thể sửa lại nếu cần.
}
{showNew && (
Tạo NCC mới vào danh mục (dùng ngay cho phiếu này)
createSup.mutate()}
disabled={!newSup.code.trim() || !newSup.name.trim() || createSup.isPending}
className="h-7 px-3 text-xs"
>
{createSup.isPending ? 'Đang tạo…' : 'Tạo & chọn'}
)}
)
}
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 phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : ''
const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
const hasError = !!(phoneError || emailError)
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 (
Hủy
mut.mutate()} disabled={hasError || mut.isPending}>Lưu
>}
>
)
}
// ===== Tab: Hạng mục + Báo giá (Session 20 — nested cards layout) =====
// Mỗi hạng mục = 1 card với expand panel chứa NCC tham gia inline grid.
// Replace bảng matrix grid (hạng mục × NCC) cũ — user demo 1 hạng mục.
function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState(null)
// S61 — Budget comparison per-row (cột "NS link" + Δ) XÓA: module Budget bỏ hẳn,
// không còn link PE → Budget entity row-by-row. So sánh ngân sách giờ ở bảng
// TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (Section 2 — PeBudgetSummaryTable).
return (
{ev.details.length} hạng mục · {ev.suppliers.length} NCC tham gia
{!readOnly && ' — mở hạng mục để thêm NCC + nhập báo giá.'}
{/* S59 vòng 6 (anh chốt "bỏ luôn cái nút thêm hạng mục"): 1 phiếu = 1 hạng mục
chọn từ header (S57bis/S58) — hạng mục đầu auto-seed khi tạo phiếu, nút thêm
hạng mục thứ 2+ sai mô hình → nút đã bỏ (dialog thêm/sửa hạng mục là DetailDialog). */}
{ev.details.length === 0 ? (
Chưa có hạng mục.
) : (
{ev.details.map(d => (
setEditDetail(d)}
/>
))}
)}
{addOpen &&
setAddOpen(false)} />}
{editDetail && setEditDetail(null)} />}
)
}
// Card 1 hạng mục — tầng 1 header + tầng 2 NCC grid inline expand.
// Mặc định mở (expanded=true) vì user demo chỉ 1 hạng mục, đỡ click.
function HangMucCard({
detail, ev, readOnly, onEditDetail,
}: {
detail: PeDetailRow
ev: PeDetailBundle
readOnly: boolean
onEditDetail: () => void
}) {
const qc = useQueryClient()
const [expanded, setExpanded] = useState(true)
const [addNccOpen, setAddNccOpen] = useState(false)
const [editNccRow, setEditNccRow] = useState(null)
const [quoteEdit, setQuoteEdit] = useState<{ supplier: PeSupplier; existing: PeQuote | null } | null>(null)
const removeDetail = useMutation({
mutationFn: async () => api.delete(`/purchase-evaluations/${ev.id}/details/${detail.id}`),
onSuccess: () => { toast.success('Đã xóa hạng mục.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
})
const removeNcc = 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)),
})
// [multi-NCC per hạng mục] Winner CHỌN THEO HẠNG MỤC NÀY: nguồn sự thật = quote.isSelected
// của detail này. BE select-winner nhận MASTER supplierId (map qua PeSupplier.supplierId) +
// detailId → set IsSelected cho quotes hạng mục này, derive Supplier.IsWinner toàn phiếu.
// Gửi TOÀN BỘ tập master-id đang thắng hạng mục này sau mỗi toggle (chọn ≥2 = liên danh).
const selectedRowIds = new Set(detail.quotes.filter(q => q.isSelected).map(q => q.purchaseEvaluationSupplierId))
const detailWinnerSupplierIds = ev.suppliers.filter(s => selectedRowIds.has(s.id)).map(s => s.supplierId)
const setDetailWinners = useMutation({
mutationFn: async (supplierIds: string[]) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { detailId: detail.id, supplierIds }),
onSuccess: () => {
toast.success('Đã cập nhật NCC trúng thầu cho hạng mục.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const toggleDetailWinner = (supplierId: string, on: boolean) =>
setDetailWinners.mutate(on
? [...detailWinnerSupplierIds, supplierId]
: detailWinnerSupplierIds.filter(id => id !== supplierId))
return (
{/* Header row — hạng mục info + actions. Session 20 turn 11: flex-wrap +
padding responsive cho laptop nhỏ. Stat (Số tiền NS) wrap xuống dòng
riêng khi container hẹp. */}
setExpanded(!expanded)}
className="mt-0.5 text-slate-400 hover:text-slate-700"
title={expanded ? 'Đóng' : 'Mở'}
>
{expanded ? : }
{detail.groupCode}
{detail.noiDung}
{detail.groupName}{detail.donViTinh ? ` · ĐVT: ${detail.donViTinh}` : ''}
Số tiền ngân sách
{fmtMoney(detail.thanhTienNganSach)}
đ
{/* [S61 Mig 50] Cột "NS link" so sánh BudgetDetails cũ ĐÃ GỠ — module
Budget cũ xóa hẳn; so sánh ngân sách giờ ở bảng "Tổng hợp ngân sách
trình ký" cấp phiếu (PeBudgetSummaryTable). */}
{!readOnly && (
{ if (confirm('Xóa hạng mục? Báo giá NCC đã nhập cũng sẽ mất.')) removeDetail.mutate() }}
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa hạng mục"
>
)}
{/* Expand panel — NCC tham gia + báo giá inline */}
{expanded && (
NCC tham gia ({ev.suppliers.length})
{!readOnly && (
setAddNccOpen(true)} className="gap-1.5 text-xs">
Thêm NCC
)}
{ev.suppliers.length === 0 ? (
{readOnly ? 'Chưa có NCC tham gia.' : 'Chưa có NCC. Thêm NCC để nhập báo giá.'}
) : (
{/* S59 UAT vòng 3: "thêm file giao diện bị thay đổi không cân xứng" — auto-layout
để cell File (chip tên dài) phình + bóp dọc cột NCC. Fix: table-fixed + width
từng cột (chip file/email có truncate sẵn — kích hoạt khi cell khóa width);
min-w để panel hẹp thì scroll ngang (wrapper overflow-x-auto) thay vì bóp nát. */}
NCC
SĐT
Email
Điều khoản TT
File báo giá
Số tiền
{!readOnly && }
{ev.suppliers.map((s, idx) => {
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
// Thắng HẠNG MỤC NÀY = quote của cell có IsSelected. isWinner (phiếu-wide,
// derived BE) chỉ dùng guard xóa/sửa NCC — 1 NCC có thể thắng hạng mục khác.
const cellSelected = q?.isSelected ?? false
const isWinner = s.isWinner
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes
const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
const palette = NCC_PALETTES[idx % NCC_PALETTES.length]
return (
{cellSelected && ✓ }
{s.supplierName}
{s.displayName && {s.displayName}
}
{s.note && {s.note}
}
{s.contactPhone || — }
{s.contactEmail
? {s.contactEmail}
: — }
{s.paymentTermText ?? — }
a.purchaseEvaluationSupplierId === s.id)}
readOnly={readOnly}
/>
{!readOnly ? (
{q ? `${fmtMoney(q.thanhTien)} đ` : '+ Nhập số tiền'}
) : (
{q ? `${fmtMoney(q.thanhTien)} đ` : — }
)}
{!readOnly && (
toggleDetailWinner(s.supplierId, !cellSelected)}
disabled={setDetailWinners.isPending}
className={cn(
'rounded px-1 py-0.5 disabled:opacity-50',
cellSelected ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)}
title={cellSelected ? 'Bỏ chọn trúng thầu hạng mục này (chọn ≥2 NCC = liên danh)' : 'Chọn trúng thầu hạng mục này (chọn ≥2 NCC = liên danh)'}
>
{!isWinner && (
setEditNccRow(s)}
className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100"
title="Sửa thông tin NCC"
>
)}
{canDelete ? (
{ if (confirm('Xóa NCC này khỏi phiếu?')) removeNcc.mutate(s.id) }}
className="rounded px-1 py-0.5 text-red-500 hover:bg-red-50"
title="Xóa NCC"
>
) : !isWinner && hasQuotes && (
)}
)}
)
})}
)}
)}
{addNccOpen &&
setAddNccOpen(false)} />}
{editNccRow && setEditNccRow(null)} />}
{quoteEdit && (
setQuoteEdit(null)}
/>
)}
)
}
function DetailDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeDetailRow | null; onClose: () => void }) {
const qc = useQueryClient()
// Session 20 turn 5: user yêu cầu rút gọn — chỉ Tên hạng mục + Số tiền
// ngân sách (VND format) + Ghi chú. Các field schema khác (groupCode/
// groupName/itemCode/donViTinh/khoiLuongs/donGia) giữ default cho BE
// schema backward compat — KHÔNG expose UI cho user.
const [form, setForm] = useState({
groupCode: row?.groupCode ?? '01',
groupName: row?.groupName ?? 'Hạng mục chính',
itemCode: row?.itemCode ?? '',
noiDung: row?.noiDung ?? '',
donViTinh: row?.donViTinh ?? 'gói',
khoiLuongNganSach: row?.khoiLuongNganSach ?? 1,
khoiLuongThiCong: row?.khoiLuongThiCong ?? 1,
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)),
})
// Sync ngân sách: user nhập "Số tiền ngân sách" → set cả donGia + thanhTien
// (KL = 1 ngầm). BE giữ schema 3 field.
const setBudgetAmount = (n: number) => {
setForm({ ...form, donGiaNganSach: n, thanhTienNganSach: n })
}
return (
Hủy
mut.mutate()} disabled={mut.isPending}>{row ? 'Lưu' : 'Thêm'}
>}
>
)
}
function QuoteDialog({
evaluationId, detailId, supplierRowId, supplierName, itemName, existing, onClose,
}: {
evaluationId: string
detailId: string
supplierRowId: string
supplierName: string
itemName: string
existing: PeQuote | null
onClose: () => void
}) {
const qc = useQueryClient()
// Session 20 turn 3: user yêu cầu "tạm thời chỉ cần nhập số tiền, không
// cần 3 cột có VAT / không VAT / tổng". UI chỉ 1 input thanhTien; bgVat /
// chuaVat / note vẫn gửi BE giữ schema (default 0 / empty cho row mới,
// giữ giá trị cũ nếu existing).
const [form, setForm] = useState({
thanhTien: existing?.thanhTien ?? 0,
})
// [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG
// (KHÔNG nhét vào form: setForm({thanhTien}) thay-thế cả object sẽ nuốt mất). Init theo
// dấu quote đang mở. Toggle ghi giá-trị-CÓ-DẤU vào form.thanhTien NGAY (POST commit thẳng).
const [neg, setNeg] = useState((existing?.thanhTien ?? 0) < 0)
const mut = useMutation({
mutationFn: async () =>
api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
purchaseEvaluationSupplierId: supplierRowId,
bgVat: existing?.bgVat ?? 0,
chuaVat: existing?.chuaVat ?? 0,
thanhTien: form.thanhTien,
note: existing?.note ?? '',
isSelected: existing?.isSelected ?? false,
}),
onSuccess: () => { toast.success('Đã lưu số tiền.'); 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.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
onError: e => toast.error(getErrorMessage(e)),
})
const isSaving = mut.isPending || del.isPending
return (
{existing && del.mutate()} disabled={isSaving}>{del.isPending ? 'Đang xóa…' : 'Xóa'} }
Hủy
mut.mutate()} disabled={isSaving}>{mut.isPending ? 'Đang lưu…' : 'Lưu'}
>}
>
{isSaving && (
{mut.isPending ? 'Đang lưu…' : 'Đang xóa…'}
)}
Hạng mục: {itemName}
Số tiền
VND — nhập số, tự format dấu chấm ngàn (vd 1.000.000). Bấm +/− để nhập số âm (phát sinh giảm / hoàn tiền NCC).
)
}
// ===== Tab: Duyệt =====
// Plan AC S25 Bug 3 — Decision badge phân biệt Approve / Trả lại / Từ chối.
// Plan AD S25 — Drop fromPhase→toPhase badges (gây nhầm khi cùng ChoDuyet);
// thay bằng next-target hint parse từ comment để rõ "gửi duyệt cho ai / trả về đâu".
const PE_DECISION_REJECT = 2
function decisionBadge(decision: number, toPhase: number): { label: string; cls: string } {
if (decision === PE_DECISION_REJECT) {
// Reject phân biệt: TuChoi(99) = "Từ chối" / TraLai(98) hoặc ChoDuyet(10) = "Trả lại"
if (toPhase === 99) return { label: 'Từ chối', cls: 'bg-rose-100 text-rose-700 border border-rose-200' }
return { label: 'Trả lại', cls: 'bg-amber-100 text-amber-700 border border-amber-200' }
}
return { label: 'Duyệt', cls: 'bg-emerald-100 text-emerald-700 border border-emerald-200' }
}
// Plan AD S25 — Parse comment để show next-target hint rõ ràng. BE comment
// format chuẩn từ Service:
// Approve advance Cấp: "Hoàn tất Cấp X, sang Cấp Y cùng Bước Z"
// Approve advance Bước: "Hoàn tất Bước X/Y, sang Bước Z (Cấp 1)"
// Approve skipToFinal: "[Duyệt vượt cấp tới Cấp cuối] ..." (Plan AC)
// Approve terminal: toPhase=DaDuyet(20)
// Reject OneLevel: "Trả về Cấp X (cùng Bước Y)" hoặc "không lùi được"
// Reject OneStep: "Trả về Bước X Cấp Y" hoặc "không lùi được"
// Reject Assignee: "Trả về Người chỉ định — Bước X (...) Cấp Y"
// Reject Drafter: "Trả về Người soạn thảo"
// Reject TuChoi: toPhase=TuChoi(99)
function extractNextTargetHint(decision: number, toPhase: number, comment: string | null): string {
if (decision === PE_DECISION_REJECT) {
if (toPhase === 99) return '→ Từ chối hoàn toàn'
const c = comment ?? ''
if (c.includes('không lùi được')) return '→ Không lùi được'
if (c.includes('Người chỉ định')) {
const m = c.match(/Bước\s*(\d+).*?Cấp\s*(\d+)/)
return m ? `→ Trả về Người chỉ định (Bước ${m[1]} Cấp ${m[2]})` : '→ Trả về Người chỉ định'
}
if (c.includes('Người soạn thảo') || c.includes('Drafter')) return '→ Trả về Người soạn thảo'
if (c.includes('Trả về 1 Cấp') || c.includes('Trả về Cấp')) {
const m = c.match(/Cấp\s*(\d+)/)
return m ? `→ Lùi về Cấp ${m[1]}` : '→ Lùi 1 Cấp'
}
if (c.includes('Trả về 1 Bước') || c.includes('Trả về Bước')) {
const m = c.match(/Bước\s*(\d+)/)
return m ? `→ Lùi về Bước ${m[1]}` : '→ Lùi 1 Bước'
}
return ''
}
// Approve
if (toPhase === 20) return '→ Đã duyệt hoàn tất'
const c = comment ?? ''
if (c.includes('Duyệt vượt cấp') || c.includes('Approver skip thẳng tới')) {
return '→ Vượt cấp tới Cấp cuối'
}
const levelMatch = c.match(/sang Cấp\s*(\d+)/)
if (levelMatch) return `→ Cấp ${levelMatch[1]}`
const stepMatch = c.match(/sang Bước\s*(\d+)/)
if (stepMatch) return `→ Bước ${stepMatch[1]} (Cấp 1)`
return ''
}
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
// Plan AC2 S25 — FE merge view: fetch changelogs + reconstruct synthetic
// Reject rows từ pre-Plan AC historical data (PE cũ deploy trước 2026-05-19
// KHÔNG có Approval row cho Reject vì BE cũ chỉ log Changelog). Merge approvals
// + synthetic + dedupe timestamp 5s bucket cùng approverUserId.
const changelogs = useQuery({
queryKey: ['pe-changelog', ev.id],
queryFn: async () => (await api.get(`/purchase-evaluations/${ev.id}/changelogs`)).data,
})
// Plan AF S25 — userMap fallback cho historical entries pre-Plan AE
// (userName="" empty/null). Cover real approvals + synthetic reject rows.
const userMap = useMemo(() => {
const m = new Map()
if (ev.drafterUserId && ev.drafterName) m.set(ev.drafterUserId, ev.drafterName)
ev.approvals.forEach(a => {
if (a.approverUserId && a.approverName) m.set(a.approverUserId, a.approverName)
})
ev.approvalFlow?.steps?.forEach(s =>
s.levels?.forEach(l =>
l.approvers?.forEach(ap => {
if (ap.userId && ap.fullName) m.set(ap.userId, ap.fullName)
}),
),
)
ev.levelOpinions?.forEach(o => {
if (o.signedByUserId && o.signedByFullName) m.set(o.signedByUserId, o.signedByFullName)
})
ev.departmentOpinions?.forEach(o => {
if (o.userId && o.userName) m.set(o.userId, o.userName)
})
return m
}, [ev])
const resolveActorName = (a: PeApproval): string => {
if (a.approverName && a.approverName.trim() !== '') return a.approverName
if (a.approverUserId) {
const name = userMap.get(a.approverUserId)
if (name) return name
}
return 'Hệ thống'
}
const merged = useMemo(() => {
const phaseEnumMap: Record = {
DangSoanThao: 1, ChoDuyet: 10, DaDuyet: 20, TraLai: 98, TuChoi: 99,
}
const PE_ENTITY_WORKFLOW = 5
const syntheticRejects: PeApproval[] = (changelogs.data ?? [])
.filter(c => {
if (c.entityType !== PE_ENTITY_WORKFLOW) return false
if (c.summary?.includes('→ TraLai') || c.summary?.includes('→ TuChoi')) return true
// 3 mode (OneLevel/OneStep/Assignee) giữ ChoDuyet → distinguish qua ContextNote keywords
const note = c.contextNote ?? ''
return note.includes('Trả về') || note.includes('không lùi được')
})
.map(c => {
const m = c.summary?.match(/Chuyển phase (\w+) → (\w+)/)
const fromPhase = m ? (phaseEnumMap[m[1]] ?? 10) : 10
const toPhase = m ? (phaseEnumMap[m[2]] ?? 10) : 10
return {
id: `syn-${c.id}`,
fromPhase,
toPhase,
approverUserId: c.userId ?? null,
approverName: c.userName ?? null,
decision: 2,
comment: c.contextNote ?? c.summary ?? null,
approvedAt: c.createdAt,
}
})
const realRejectKeys = new Set(
ev.approvals
.filter(a => a.decision === 2)
.map(a => `${a.approverUserId ?? ''}-${Math.floor(new Date(a.approvedAt).getTime() / 5000)}`),
)
const dedupedSynthetic = syntheticRejects.filter(s =>
!realRejectKeys.has(`${s.approverUserId ?? ''}-${Math.floor(new Date(s.approvedAt).getTime() / 5000)}`),
)
return [...ev.approvals, ...dedupedSynthetic]
.sort((a, b) => new Date(a.approvedAt).getTime() - new Date(b.approvedAt).getTime())
}, [ev.approvals, changelogs.data])
if (merged.length === 0) return Chưa có bước duyệt nào.
return (
{merged.map(a => {
const dec = decisionBadge(a.decision, a.toPhase)
const hint = extractNextTargetHint(a.decision, a.toPhase, a.comment)
return (
{dec.label}
{hint && {hint} }
{new Date(a.approvedAt).toLocaleString('vi-VN')}
{resolveActorName(a)}{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,
})
// Plan AF S25 — userMap fallback cho historical entries pre-Plan AE
const userMap = useMemo(() => {
const m = new Map()
if (ev.drafterUserId && ev.drafterName) m.set(ev.drafterUserId, ev.drafterName)
ev.approvals.forEach(a => {
if (a.approverUserId && a.approverName) m.set(a.approverUserId, a.approverName)
})
ev.approvalFlow?.steps?.forEach(s =>
s.levels?.forEach(l =>
l.approvers?.forEach(ap => {
if (ap.userId && ap.fullName) m.set(ap.userId, ap.fullName)
}),
),
)
ev.levelOpinions?.forEach(o => {
if (o.signedByUserId && o.signedByFullName) m.set(o.signedByUserId, o.signedByFullName)
})
ev.departmentOpinions?.forEach(o => {
if (o.userId && o.userName) m.set(o.userId, o.userName)
})
return m
}, [ev])
const resolveUserName = (l: PeChangelog): string => {
if (l.userName && l.userName.trim() !== '') return l.userName
if (l.userId) {
const name = userMap.get(l.userId)
if (name) return name
}
return 'Hệ thống'
}
if (logs.isLoading) return Đang tải…
// User UAT 2026-05-08: chỉ track events Trả lại + Gửi duyệt lại.
// User UAT 2026-05-19: + track Budget Adjust (Bug 1) + 4 mode Trả lại (Bug 2).
// Filter giữ:
// - Workflow transition về TraLai (phaseAtChange = TraLai = 98)
// - Workflow transition từ TraLai → khác (Drafter gửi lại — summary "TraLai →")
// - Workflow Trả lại 4 mode (summary chứa "Trả lại" — Plan AB S25 fix Bug 2)
// - Header Budget Adjust (summary chứa "ngân sách" — Plan AB S25 fix Bug 1)
// - Mọi thay đổi nội dung khi phaseAtChange = TraLai (Drafter sửa trước gửi lại)
// BE giữ data đầy đủ (audit trail) — chỉ filter ở UI, reversible.
const PE_PHASE_TRALAI = 98
const PE_ENTITY_WORKFLOW = 5
const PE_ENTITY_HEADER = 1
const filtered = (logs.data ?? []).filter(l => {
if (l.entityType === PE_ENTITY_WORKFLOW) {
if (l.phaseAtChange === PE_PHASE_TRALAI) return true
if (l.summary?.includes('TraLai →')) return true
if (l.summary?.includes('Trả lại')) return true
return false
}
if (l.entityType === PE_ENTITY_HEADER && l.summary?.toLowerCase().includes('ngân sách')) {
return true
}
return l.phaseAtChange === PE_PHASE_TRALAI
})
if (filtered.length === 0) return Chưa có lịch sử trả lại / điều chỉnh ngân sách / gửi duyệt lại.
return (
{filtered.map(l => (
{resolveUserName(l)}
{new Date(l.createdAt).toLocaleString('vi-VN')}
{l.summary}
{l.contextNote && {l.contextNote}
}
))}
)
}
// ===== Cell upload file đính kèm per-NCC =====
// 1 row = 1 NCC. User upload file báo giá (purpose=QuoteDocument mặc định) →
// POST multipart với supplierRowId. List N file hiện có + Download/Delete inline.
// Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
function SupplierAttachmentsCell({
evaluationId,
supplierRowId,
attachments,
readOnly = false,
}: {
evaluationId: string
supplierRowId: string
attachments: PeAttachment[]
readOnly?: boolean
}) {
const qc = useQueryClient()
const fileInputRef = useRef(null)
const [previewAtt, setPreviewAtt] = useState(null)
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
fd.append('supplierRowId', supplierRowId)
fd.append('purpose', String(PeAttachmentPurpose.QuoteDocument))
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => {
toast.success('Đã tải lên.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async (attId: string) =>
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
onSuccess: () => {
toast.success('Đã xóa.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
async function download(att: PeAttachment) {
try {
const res = await api.get(
`/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
)
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
toast.error(getErrorMessage(e))
}
}
async function onPick(e: React.ChangeEvent) {
// S59 UAT "mỗi lần chỉ chọn được 1 file" → input multiple, upload tuần tự từng file.
const files = Array.from(e.target.files ?? [])
e.target.value = ''
for (const f of files) {
try { await upload.mutateAsync(f) } catch { /* toast lỗi đã hiện ở onError */ }
}
}
const fmtSize = (b: number) =>
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
return (
{attachments.length === 0 && (
Chưa có file
)}
{attachments.map(a => (
{a.fileName}
{fmtSize(a.fileSize)}
{PeAttachmentPurposeLabel[a.purpose] ?? ''}
{isPreviewable(a.fileName) && (
setPreviewAtt(a)}
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
title="Xem trước"
>
)}
download(a)}
className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50"
title="Tải xuống"
>
{!readOnly && (
{ if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
)}
))}
{previewAtt && (
setPreviewAtt(null)}
/>
)}
{!readOnly && (
fileInputRef.current?.click()}
disabled={upload.isPending}
className="inline-flex items-center gap-1 rounded border border-dashed border-slate-300 px-2 py-0.5 text-[11px] text-slate-500 hover:border-brand-300 hover:text-brand-700 disabled:opacity-50"
>
{upload.isPending ? 'Đang tải…' : '+ Thêm file'}
)}
)
}
// ===== Section Bảng so sánh — general attachments (không gắn NCC cụ thể) =====
// Purpose mặc định = ComparisonTable (4). Upload file Excel/PDF tổng hợp so
// sánh giá N NCC × M hạng mục. Storage path giống SupplierAttachmentsCell
// nhưng supplierRowId KHÔNG truyền → BE lưu NULL.
function GeneralAttachmentsSection({
evaluationId,
attachments,
readOnly = false,
}: {
evaluationId: string
attachments: PeAttachment[]
readOnly?: boolean
}) {
const qc = useQueryClient()
const fileInputRef = useRef(null)
const [previewAtt, setPreviewAtt] = useState(null)
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
// KHÔNG append supplierRowId → BE set NULL → general attachment
fd.append('purpose', String(PeAttachmentPurpose.ComparisonTable))
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => {
toast.success('Đã tải lên bảng so sánh.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async (attId: string) =>
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
onSuccess: () => {
toast.success('Đã xóa.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
async function download(att: PeAttachment) {
try {
const res = await api.get(
`/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
)
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
toast.error(getErrorMessage(e))
}
}
async function onPick(e: React.ChangeEvent) {
// S59 UAT "mỗi lần chỉ chọn được 1 file" → input multiple, upload tuần tự từng file.
const files = Array.from(e.target.files ?? [])
e.target.value = ''
for (const f of files) {
try { await upload.mutateAsync(f) } catch { /* toast lỗi đã hiện ở onError */ }
}
}
const fmtSize = (b: number) =>
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
return (
{!readOnly && (
File Excel/PDF tổng hợp so sánh giá của tất cả NCC (không gắn với 1 NCC cụ thể).
)}
{attachments.length === 0 && readOnly && (
Chưa có bảng so sánh.
)}
{attachments.length > 0 && (
{attachments.map(a => (
{a.fileName}
{fmtSize(a.fileSize)}
{PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'}
{new Date(a.createdAt).toLocaleDateString('vi-VN')}
{isPreviewable(a.fileName) && (
setPreviewAtt(a)}
className="shrink-0 rounded p-1 text-violet-600 hover:bg-violet-50"
title="Xem trước"
>
)}
download(a)}
className="shrink-0 rounded p-1 text-brand-600 hover:bg-brand-50"
title="Tải xuống"
>
{!readOnly && (
{ if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded p-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
)}
))}
)}
{previewAtt && (
setPreviewAtt(null)}
/>
)}
{!readOnly && (
fileInputRef.current?.click()}
disabled={upload.isPending}
className="inline-flex items-center gap-1.5 rounded border border-dashed border-brand-300 bg-brand-50/50 px-3 py-2 text-xs font-medium text-brand-700 hover:border-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
{upload.isPending ? 'Đang tải…' : '+ Tải lên bảng so sánh'}
)}
)
}