// 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 } from 'react'
import { 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, Wallet } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog'
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 { BudgetPhase, type BudgetListItem } from '@/types/budget'
import type { Paged, 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
// 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
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)),
})
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
const canSubmitForApproval = mode === 'workspace'
&& canEditPhase
&& !readOnly
&& forwardPhase != null
// 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).
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.`
: null
return (
{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]})
{readOnly && (
chế độ duyệt
)}
{/* 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 && (
{/* 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 && (
)}
✓ Các thay đổi đã tự động lưu khi chỉnh sửa từng phần.
)}
)
}
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 (
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.
)
}
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
✓ Phiếu đã duyệt. Bấm để tạo HĐ mới kế thừa NCC + hạng mục.
)}
{createOpen && setCreateOpen(false)} />}
)
}
// 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 () =>
api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form),
onSuccess: res => {
toast.success('Đã tạo HĐ từ phiếu.')
navigate(`/contracts/${res.data.contractId}`)
},
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 (
)
}
// 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 } })).data.items,
})
const [form, setForm] = useState({
supplierId: '',
displayName: '',
contactName: '',
contactEmail: '',
contactPhone: '',
paymentTermText: '',
note: '',
thanhTien: 0,
})
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
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)
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 (
)
}
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 (
)
}
// ===== 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)
// Budget comparison — fetch full Budget bundle nếu có link để so sánh per-row.
const budgetBundle = useQuery({
queryKey: ['budget-detail-for-pe', ev.budgetId],
queryFn: async () => (await api.get<{ details: { groupCode: string; itemCode: string | null; thanhTien: number }[]; tongNganSach: number }>(
`/budgets/${ev.budgetId}`)).data,
enabled: !!ev.budgetId,
})
const budgetRowMap = (() => {
const m = new Map()
budgetBundle.data?.details.forEach(d => {
m.set(`${d.groupCode}|${d.itemCode ?? ''}`, d.thanhTien)
})
return m
})()
const showBudgetCol = !!ev.budgetId
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á.'}