// Panel 3: workflow timeline + transition buttons + approval history + changelog. // Pulls nextPhases từ BE bundle (single source of truth) → render per-phase // action button. Approvals + History moved here from PeDetailTabs (2 section // dưới cùng) để Panel 2 tập trung hiển thị nội dung phiếu (Info + NCC + Items). import { useEffect, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { Download, Eye, Paperclip, Upload, X } from 'lucide-react' import { Dialog } from '@/components/ui/Dialog' import { Button } from '@/components/ui/Button' import { Label } from '@/components/ui/Label' import { Textarea } from '@/components/ui/Textarea' import { api } from '@/lib/api' import { getErrorMessage } from '@/lib/apiError' import { cn } from '@/lib/cn' import { useAuth } from '@/contexts/AuthContext' import { ApprovalStage, PeAttachmentPurpose, PurchaseEvaluationPhase, PurchaseEvaluationPhaseColor, PurchaseEvaluationPhaseLabel, WorkflowReturnMode, type PeAttachment, type PeDepartmentApproval, type PeDetailBundle, } from '@/types/purchaseEvaluation' import { AttachmentPreviewDialog } from './AttachmentPreviewDialog' import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs' export function PeWorkflowPanel({ evaluation, readOnly = false, onApproved, }: { evaluation: PeDetailBundle /** true = ẩn Chuyển tiếp + Dialog transition (dùng cho Danh sách, không dùng Duyệt). */ readOnly?: boolean /** S78 — gọi sau khi DUYỆT (forward approve) thành công, để caller đóng focus * overlay về danh sách (anh: "chọn duyệt thì nó đóng lại như ban đầu"). * KHÔNG gọi khi Trả lại / Từ chối (caller giữ overlay để xem kết quả). */ onApproved?: () => void }) { const [target, setTarget] = useState(null) const [comment, setComment] = useState('') // Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17). const [returnMode, setReturnMode] = useState(WorkflowReturnMode.Drafter) const [returnTargetUserId, setReturnTargetUserId] = useState(null) // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối. Default false (admin opt-in // per slot tick → checkbox visible trong dialog Approve, default unchecked). const [skipToFinalApprover, setSkipToFinalApprover] = useState(false) // [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done miễn CEO" + ① người duyệt // cuối chọn 1 giá chốt (Ncc / ProMin / ProMax / Ccm). const [finalizeByCcm, setFinalizeByCcm] = useState(false) const [approvedPriceSource, setApprovedPriceSource] = useState(null) const qc = useQueryClient() const { user: currentUser } = useAuth() const isAdmin = currentUser?.roles?.includes('Admin') ?? false // S78 — người duyệt đính kèm file khi DUYỆT. File chọn (chưa upload) staged ở state, // upload trong transition.mutationFn TRƯỚC khi chuyển phase (file lỗi = không duyệt). const [approveFiles, setApproveFiles] = useState([]) const approveFileInputRef = useRef(null) const [previewAtt, setPreviewAtt] = useState(null) // Mig 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại). const levelOptions = evaluation.currentLevelOptions // S23 t2 fix bro UAT: khi admin tick AllowReturnToAssignee/OneLevel/OneStep // (mode đang gửi duyệt) + UNTICK AllowReturnToDrafter, dialog default mode // phải là first F1 available — KHÔNG để Drafter (radio hidden) làm initial // → user click Xác nhận sai mode → BE throw "không bật mode Drafter". // Drafter chỉ làm fallback khi không có F1 nào enabled. // Per bro intent: "draft chỉ khi trả lại cho người soạn thôi" — 3 F1 modes // mới là "trả lại trong mode đang gửi duyệt". useEffect(() => { if (target === PurchaseEvaluationPhase.TraLai) { const firstAvailable = levelOptions?.allowReturnOneLevel ? WorkflowReturnMode.OneLevel : levelOptions?.allowReturnOneStep ? WorkflowReturnMode.OneStep : levelOptions?.allowReturnToAssignee ? WorkflowReturnMode.Assignee : WorkflowReturnMode.Drafter setReturnMode(firstAvailable) } }, [target, levelOptions]) // List approvers đã ký (cho mode Assignee dropdown pick) const signedApprovers = (evaluation.levelOpinions ?? []) .map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' })) .filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i) // Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers // thao tác cấp hiện tại. UAT S22+1 feedback: "không quyền thao tác = ko quyền // mọi hành động" — disable cả 3 button (Duyệt + Trả lại + Từ chối) khi actor // không match currentLevel.ApproverUserId. BE mirror guard trong // EnsureCanRejectV2Async (defense-in-depth — UI disable + BE reject). // Admin bypass. const v2Approvers = evaluation.currentApproval?.approvers ?? [] const actorInV2Level = isAdmin || (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id)) // V2 active = phiếu pin V2 + Phase=ChoDuyet + có currentApproval const isV2Pending = !!evaluation.currentApproval const blockedByV2Level = isV2Pending && !actorInV2Level // [Mig 54] ③ CCM duyệt-done miễn CEO: chỉ đủ điều kiện khi user là CCM (CostControl) + // workflow có ngưỡng CEO + giá gói < ngưỡng (khớp guard fail-closed BE). ① bộ chọn giá // chốt hiện khi đây là duyệt CUỐI (Cấp cuối Bước cuối đang "Current") HOẶC CCM tích done. const isCcm = currentUser?.roles?.includes('CostControl') ?? false const ccmDelegationEligible = isCcm && evaluation.ceoApprovalThreshold != null && evaluation.winnerQuoteTotal < evaluation.ceoApprovalThreshold const flowStepsForFinal = evaluation.approvalFlow?.steps ?? [] const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1] const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1] const currentIsFinalApprover = lastFlowLevel?.status === 'Current' // [Mig 58 item 4] Cấp Approver hiện tại được cấu hình KẾT THÚC trong Quy trình duyệt // (admin bật per-slot ở Designer). Duyệt tới cấp này = TỰ kết thúc (BE auto theo cờ cấp, // KHÔNG cần ô-tích). FE dùng cờ này để: (1) hiện banner cảnh báo + (2) bắt chọn giá chốt. const approverFinalizeEligible = levelOptions?.allowApproverFinalize === true // [S89 anh Kiệt FDC] Cấp "Duyệt thay CEO" (finalize) ĐẦU-TIÊN theo thứ tự duyệt trong flow → // badge cấp đó + làm mờ Bước/Cấp SAU ("không trình tới") để người xem phiếu biết quy trình // DỪNG ở cấp này (duyệt thay CEO), không lên Ban Giám đốc. const finalizeFlowStep = evaluation.approvalFlow?.steps.find(s => s.levels.some(l => l.allowApproverFinalize)) ?? null const finalizeFlowLevelOrder = finalizeFlowStep?.levels.find(l => l.allowApproverFinalize)?.order ?? null const isStepAfterFinalize = (stepOrder: number) => finalizeFlowStep != null && stepOrder > finalizeFlowStep.order const isLevelAfterFinalize = (stepOrder: number, levelOrder: number) => isStepAfterFinalize(stepOrder) || (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order && finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder) // Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null). const priceCandidates = ([ { source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal }, { source: 'ProMin', label: 'PRO — Giá Min', amount: evaluation.proSuggestedMinPrice }, { source: 'ProMax', label: 'PRO — Giá Max', amount: evaluation.proSuggestedMaxPrice }, { source: 'Ccm', label: 'CCM đề xuất', amount: evaluation.ccmSuggestedPrice }, ] as { source: string; label: string; amount: number | null | undefined }[]) .filter((c): c is { source: string; label: string; amount: number } => c.amount != null) const selectedPriceAmount = priceCandidates.find(c => c.source === approvedPriceSource)?.amount ?? null // 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel // hiển thị progress per phase × dept (Stage Review NV / Confirm TPB). const { data: deptApprovals = [] } = useQuery({ queryKey: ['pe-dept-approvals', evaluation.id], queryFn: async () => { const r = await api.get(`/purchase-evaluations/${evaluation.id}/department-approvals`) return r.data }, }) const transition = useMutation({ mutationFn: async () => { // Decision = Reject (2) khi: // - target = TuChoi (huỷ phiếu) // - target = DangSoanThao từ phase trung gian (= Trả lại legacy Mig 16 // set RejectedFromPhase + clear N-stage rows + Drafter resume jump-back) // - target = TraLai (98) từ phase trung gian — Session 17 spec mới: Trả // lại là Phase RIÊNG (gotcha #45 — thiếu nhánh này gây "Trả về nhưng // hệ thống vẫn duyệt" do BE nhận decision=Approve → ApproveV2Async). // BE có guard mirror trong PurchaseEvaluationWorkflowService.TransitionAsync // throw ConflictException nếu payload mismatch — phải sync 2 phía. const isReject = target === PurchaseEvaluationPhase.TuChoi || (target === PurchaseEvaluationPhase.DangSoanThao && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao) || (target === PurchaseEvaluationPhase.TraLai && evaluation.phase !== PurchaseEvaluationPhase.TraLai) // Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai && evaluation.phase !== PurchaseEvaluationPhase.TraLai // [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done // HOẶC [Mig 58] cấp được cấu hình KẾT THÚC (đều là điểm CHỐT giá). const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible) // [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi // chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE). // File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side). if (!isReject && approveFiles.length > 0) { for (const f of approveFiles) { const fd = new FormData() fd.append('file', f) fd.append('purpose', String(PeAttachmentPurpose.ApprovalAttachment)) fd.append('note', 'Đính kèm khi duyệt') await api.post(`/purchase-evaluations/${evaluation.id}/attachments`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, }) } } return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, { targetPhase: target, decision: isReject ? 2 : 1, comment: comment || null, returnMode: isTraLaiAction ? returnMode : null, returnTargetUserId: isTraLaiAction && returnMode === WorkflowReturnMode.Assignee ? returnTargetUserId : null, // Mig 31 (S23 t1) — F2 Approver scope ChoDuyet duyệt thẳng Cấp cuối. // BE check matchingLevel.AllowApproverSkipToFinal (admin opt-in per slot). skipToFinal: !isReject && skipToFinalApprover, // [Mig 54 2026-06-18] ③ CCM duyệt done miễn CEO + ① giá chốt người duyệt chọn. finalizeByCcmDelegation: !isReject && finalizeByCcm, approvedPriceAmount: sendPrice ? selectedPriceAmount : null, approvedPriceSource: sendPrice ? approvedPriceSource : null, }) }, onSuccess: () => { toast.success('Đã chuyển phase.') qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] }) qc.invalidateQueries({ queryKey: ['pe-list'] }) qc.invalidateQueries({ queryKey: ['pe-dept-approvals', evaluation.id] }) // S78 — chỉ DUYỆT (forward approve) mới auto-đóng focus overlay. Trả lại / // Từ chối giữ overlay (mirror isReject/isSendBack trong mutationFn). const wasReject = target === PurchaseEvaluationPhase.TuChoi || (target === PurchaseEvaluationPhase.DangSoanThao && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao) || (target === PurchaseEvaluationPhase.TraLai && evaluation.phase !== PurchaseEvaluationPhase.TraLai) setTarget(null) setComment('') setReturnMode(WorkflowReturnMode.Drafter) setReturnTargetUserId(null) setSkipToFinalApprover(false) setFinalizeByCcm(false) setApprovedPriceSource(null) setApproveFiles([]) if (!wasReject) onApproved?.() }, onError: e => toast.error(getErrorMessage(e)), }) // UAT S60 (anh Kiệt 14:14): "bỏ luôn nút Từ chối — Duyệt hoặc Trả về thôi". // BE policy đã gỡ TuChoi khỏi nextPhases; filter này = defense-in-depth FE // (BE cũ cache / deploy lệch vẫn không lòi nút). Dialog/isCancel giữ dead-safe. const next = evaluation.workflow.nextPhases.filter(p => p !== PurchaseEvaluationPhase.TuChoi) const flow = evaluation.approvalFlow // [S78] File do người duyệt đính kèm trong quá trình duyệt (purpose=ApprovalAttachment). const approvalAttachments = (evaluation.attachments ?? []).filter( a => a.purpose === PeAttachmentPurpose.ApprovalAttachment, ) const fmtFileSize = (b: number) => b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB` const isPreviewable = (name: string) => /\.(pdf|png|jpe?g|webp)$/i.test(name) async function downloadAttachment(a: PeAttachment) { try { const r = await api.get( `/purchase-evaluations/${evaluation.id}/attachments/${a.id}/download`, { responseType: 'blob' }, ) const url = window.URL.createObjectURL(r.data as Blob) const link = document.createElement('a') link.href = url link.download = a.fileName link.click() window.URL.revokeObjectURL(url) } catch (e) { toast.error(getErrorMessage(e)) } } return (

Quy trình duyệt

{evaluation.approvalWorkflowCode && (

{evaluation.approvalWorkflowCode} v{String(evaluation.approvalWorkflowVersion ?? 0).padStart(2, '0')} {evaluation.approvalWorkflowName && <> · {evaluation.approvalWorkflowName}}

)}
{/* [Mig 58 AllowApproverFinalize — anh Kiệt FDC, S89] NOTE cho MỌI người NẮM: quy trình của phiếu này có cấp được cấu hình "Duyệt là KẾT THÚC" (không trình CEO). Branch theo phase: đang chạy = callout xanh-lá báo trước / Đã duyệt = xác nhận đã kết thúc tại đâu. */} {evaluation.endsBeforeCeo && (
{evaluation.phase === PurchaseEvaluationPhase.DaDuyet ? ( ✅ Phiếu đã kết thúc tại{' '} {evaluation.finalizeStepName ?? 'cấp duyệt'} {evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName} : null} — không qua CEO. ) : ( ⚑ Quy trình rút gọn: duyệt đến{' '} {evaluation.finalizeStepName ?? 'cấp này'} {evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName} : null} là KẾT THÚC, không trình CEO. Các cấp trước vẫn duyệt như thường. )}
)} {/* Mig 24 V2 — Flow render Bước → Cấp → NV thay phase cards. Status: Done (✓ emerald) / Current (● brand) / Pending (○ slate) */} {flow && flow.steps.length > 0 && (
    {flow.steps.map(step => { const stepIcon = step.status === 'Done' ? '✓' : step.status === 'Current' ? '●' : '○' return (
  1. {stepIcon} Bước {step.order} — {step.name} {step.departmentName && ( {step.departmentName} )} {isStepAfterFinalize(step.order) && ( không trình tới )}
    {step.levels.length > 0 && (
      {step.levels.map(lv => { const lvIcon = lv.status === 'Done' ? '✓' : lv.status === 'Current' ? '●' : '○' return (
    • {lvIcon}
      {lv.name || `Cấp ${lv.order}`} {lv.status === 'Current' && đang chờ} {lv.status === 'Done' && đã duyệt} {/* [S89 anh Kiệt FDC] Cấp này duyệt = KẾT THÚC quy trình, duyệt THAY CEO. */} {lv.allowApproverFinalize && ( ⚑ Duyệt thay CEO )}
      {lv.approvers.length === 0 ? '(chưa cấu hình)' : lv.approvers.map((a, i) => ( {i > 0 && /} {a.fullName} {/* [S76] Badge quyền nhập/điều chỉnh ngân sách (hiển thị-only, suy từ role) */} {a.canEditProBudget && ( ✎ NS PRO )} {a.canEditCcmBudget && ( ✎ NS CCM )} ))}
    • ) })}
    )}
  2. ) })}
)} {/* Phiếu V1 legacy không có flow → fallback hiển thị phase summary đơn giản */} {!flow && (
Phiếu này dùng quy trình cũ — workflow chi tiết không khả dụng.
)} {/* Mig 24 — V2 banner: hiển thị Bước/Cấp hiện tại + danh sách NV được duyệt. Nếu actor không có trong list → banner amber + nút Duyệt sẽ disable. */} {isV2Pending && evaluation.currentApproval && !readOnly && (
Đang chờ Bước {evaluation.currentApproval.stepIndex + 1} ({evaluation.currentApproval.stepName}) {evaluation.currentApproval.stepDepartmentName && <> · {evaluation.currentApproval.stepDepartmentName}} {' — '}Cấp {evaluation.currentApproval.levelOrder}
NV duyệt: {evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ') || '(chưa có)'}
{actorInV2Level ?
✓ Đến lượt bạn duyệt
:
⚠ Không phải lượt bạn — chỉ NV trên mới duyệt được cấp này
}
)} {next.length > 0 && !readOnly && (
{next.map(p => { // Phân loại button theo hành động (3 màu khác nhau, in đậm): // - Duyệt = forward phase tiếp theo — emerald (xanh lá positive) // - Trả lại = về DangSoanThao/TraLai (từ phase trung gian) — amber (request changes) // - Từ chối = TuChoi — red (terminal negative) const isSendBack = (p === PurchaseEvaluationPhase.DangSoanThao || p === PurchaseEvaluationPhase.TraLai) && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao && evaluation.phase !== PurchaseEvaluationPhase.TraLai const isCancel = p === PurchaseEvaluationPhase.TuChoi const isForwardApprove = !isSendBack && !isCancel // S59 anh chốt (UAT: "nhân viên tạo phiếu thì trả lại và từ chối cho ai?"): // người duyệt CHÍNH LÀ người soạn phiếu → ẩn cả Trả lại + Từ chối // (trả cho chính mình vô nghĩa — đang sửa inline được; hủy phiếu sai // = nhờ cấp khác Từ chối, phiếu Nháp có nút Xóa riêng). if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null // Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match // currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động." const isDisabled = blockedByV2Level const label = isSendBack ? '← Trả lại' : isCancel ? '✗ Từ chối' : '✓ Duyệt' const title = isDisabled && evaluation.currentApproval ? `Cấp ${evaluation.currentApproval.levelOrder} chỉ ${evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ')} mới thao tác được (Duyệt / Trả lại / Từ chối).` : isForwardApprove ? `Duyệt → ${PurchaseEvaluationPhaseLabel[p]}` : undefined return ( ) })}
)} {readOnly && next.length > 0 && (
Vào menu “Duyệt” để chuyển phase.
)} {target !== null && (() => { const isCancel = target === PurchaseEvaluationPhase.TuChoi // isSendBack sync với button label + payload isReject (gotcha #45). // Include cả DangSoanThao (legacy Mig 16) lẫn TraLai (Session 17 spec) // — cả 2 là Trả lại Drafter sửa. const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao || target === PurchaseEvaluationPhase.TraLai) && evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao && evaluation.phase !== PurchaseEvaluationPhase.TraLai const dialogTitle = isCancel ? '✗ Từ chối phiếu (khoá hoàn toàn)' : isSendBack ? '← Trả lại Drafter sửa' : `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}` // [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn. const isApproveAction = !isCancel && !isSendBack const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible) // [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates // rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh // người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge). const priceMissing = shouldPickPrice && (priceCandidates.length === 0 || !approvedPriceSource) return ( { setTarget(null); setApproveFiles([]) }} title={dialogTitle} footer={<> } > {isCancel && (
⚠ Phiếu sẽ bị khoá hoàn toàn (không edit/transition được nữa). Drafter cần tạo phiếu mới nếu muốn làm lại.
)} {isSendBack && ( <> {/* Mig 28 (S21 t4) — F1 mode picker khi Trả lại. Show modes enabled per workflow.options. Default Drafter (S17 fallback). */} {(levelOptions?.allowReturnOneLevel || levelOptions?.allowReturnOneStep || levelOptions?.allowReturnToAssignee || levelOptions?.allowReturnToDrafter || !levelOptions) && (
{(levelOptions?.allowReturnOneLevel) && ( )} {(levelOptions?.allowReturnOneStep) && ( )} {(levelOptions?.allowReturnToAssignee) && ( )} {(levelOptions?.allowReturnToDrafter !== false) && ( )}
)}
{returnMode === WorkflowReturnMode.Drafter ? 'Phiếu sẽ về "Cần chỉnh sửa lại". Drafter có thể sửa rồi trình lại từ Cấp 1 Bước 1.' : returnMode === WorkflowReturnMode.Assignee ? 'Phiếu sẽ về Cấp/Bước của NV đã chọn (vẫn "Đã gửi duyệt"). NV nhận lại để duyệt tiếp.' : 'Phiếu sẽ lùi pointer (vẫn "Đã gửi duyệt"). NV trước nhận lại để duyệt tiếp.'}
)} {/* Mig 31 (S23 t1) — F2 Approver toggle: chỉ visible khi Approve forward + admin tick AllowApproverSkipToFinal cho slot Cấp hiện tại. */} {!isCancel && !isSendBack && levelOptions?.allowApproverSkipToFinal && (
)} {!isCancel && !isSendBack && skipToFinalApprover && (
⚠ Bỏ qua mọi Cấp/Bước trung gian, phiếu chuyển thẳng tới NV cuối. NV cuối vẫn phải ký duyệt thật để phiếu thành "Đã duyệt".
)} {/* [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done, miễn CEO" — chỉ hiện khi user là CCM + gói < ngưỡng CEO uỷ quyền. */} {isApproveAction && ccmDelegationEligible && (
)} {/* [Mig 58 item 4 — anh Kiệt FDC] Cấp được cấu hình KẾT THÚC trong Quy trình duyệt: BE TỰ finalize khi duyệt tới đây (auto, không ô-tích). Banner báo + bắt chọn giá chốt. */} {isApproveAction && approverFinalizeEligible && (
✅ Cấp này KẾT THÚC quy trình Được cấu hình sẵn trong Quy trình duyệt — bạn duyệt xong là phiếu "Đã duyệt", KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới trước khi duyệt.
)} {/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done. "Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */} {shouldPickPrice && (
{priceCandidates.map(c => ( ))} {priceCandidates.length === 0 && (
Chưa có giá nào để chọn — nhập giá đề xuất (PRO/CCM) hoặc chọn NCC thắng thầu trước khi duyệt cuối.
)}
)} {/* [S78 — UAT Tra Sol] Người duyệt đính kèm file khi DUYỆT — dùng khi có thay đổi nhỏ, không muốn Trả lại phiếu. File upload TRƯỚC khi chuyển phase. */} {isApproveAction && (

Tải file của bạn lên kèm khi duyệt — dùng khi có thay đổi nhỏ, không cần Trả lại phiếu.

{ const picked = Array.from(e.target.files ?? []) e.target.value = '' if (picked.length) setApproveFiles(prev => [...prev, ...picked]) }} className="hidden" /> {approveFiles.length > 0 && (
    {approveFiles.map((f, i) => (
  • {f.name} {fmtFileSize(f.size)}
  • ))}
)}
)}