Files
solution-erp/fe-admin/src/components/pe/PeWorkflowPanel.tsx
pqhuy1987 9c920ad4c3
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m2s
[CLAUDE] FE-User · FE-Admin: PE flow sơ-đồ — badge "Duyệt thay CEO" trên cấp finalize + làm mờ Bước CEO "không trình tới"
Trong khối "Quy trình duyệt" của phiếu (PeWorkflowPanel, sơ đồ Bước→Cấp→NV), cấp được cấu hình
AllowApproverFinalize (Mig 58) giờ hiện badge tím "⚑ Duyệt thay CEO" + tooltip; các Bước/Cấp SAU
cấp finalize (vd Ban Giám đốc / CEO) làm mờ + nhãn "không trình tới" → người xem phiếu biết NGAY
trong sơ đồ rằng quy trình DỪNG ở cấp này (duyệt thay CEO), không lên CEO (anh Kiệt FDC).

Dùng field allowApproverFinalize đã expose ở flow-level (S89 #3). first-reachable theo Step.Order→
Level.Order (khớp BE finalize). Bổ sung cho callout note (#3) = chỉ-dẫn trong chính sơ đồ flow.
FE-only, 2 app SHA-mirror, build xanh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:32:34 +07:00

881 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<number | null>(null)
const [comment, setComment] = useState('')
// Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17).
const [returnMode, setReturnMode] = useState<WorkflowReturnMode>(WorkflowReturnMode.Drafter)
const [returnTargetUserId, setReturnTargetUserId] = useState<string | null>(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<string | null>(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<File[]>([])
const approveFileInputRef = useRef<HTMLInputElement>(null)
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(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<PeDepartmentApproval[]>({
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 (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-slate-900">Quy trình duyệt</h3>
{evaluation.approvalWorkflowCode && (
<p className="mt-0.5 font-mono text-[11px] text-slate-500">
{evaluation.approvalWorkflowCode} v{String(evaluation.approvalWorkflowVersion ?? 0).padStart(2, '0')}
{evaluation.approvalWorkflowName && <> · <span className="font-sans">{evaluation.approvalWorkflowName}</span></>}
</p>
)}
</div>
{/* [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 && (
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
{evaluation.phase === PurchaseEvaluationPhase.DaDuyet ? (
<span>
Phiếu đã kết thúc tại{' '}
<span className="font-semibold">{evaluation.finalizeStepName ?? 'cấp duyệt'}</span>
{evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName}</> : null} không qua CEO.
</span>
) : (
<span>
Quy trình rút gọn: duyệt đến{' '}
<span className="font-semibold">{evaluation.finalizeStepName ?? 'cấp này'}</span>
{evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName}</> : null} KẾT THÚC,
không trình CEO. Các cấp trước vẫn duyệt như thường.
</span>
)}
</div>
)}
{/* 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 && (
<ol className="space-y-2">
{flow.steps.map(step => {
const stepIcon = step.status === 'Done' ? '✓' : step.status === 'Current' ? '●' : '○'
return (
<li
key={step.order}
className={cn(
'rounded-md border p-2',
step.status === 'Current' && 'border-brand-300 bg-brand-50/50',
step.status === 'Done' && 'border-emerald-200 bg-emerald-50/40',
step.status === 'Pending' && 'border-slate-200 bg-white',
isStepAfterFinalize(step.order) && 'opacity-50',
)}
>
<div className="flex items-center gap-2 text-xs font-medium">
<span className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold',
step.status === 'Done' && 'bg-emerald-500 text-white',
step.status === 'Current' && 'bg-brand-600 text-white',
step.status === 'Pending' && 'bg-slate-200 text-slate-500',
)}>
{stepIcon}
</span>
<span className="text-slate-800">Bước {step.order} {step.name}</span>
{step.departmentName && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{step.departmentName}
</span>
)}
{isStepAfterFinalize(step.order) && (
<span
className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-400"
title="Quy trình đã kết thúc ở cấp 'Duyệt thay CEO' phía trên — bước này không được trình tới"
>
không trình tới
</span>
)}
</div>
{step.levels.length > 0 && (
<ul className="mt-1.5 ml-7 space-y-1 border-l-2 border-violet-200 pl-2">
{step.levels.map(lv => {
const lvIcon = lv.status === 'Done' ? '✓' : lv.status === 'Current' ? '●' : '○'
return (
<li key={lv.order} className={cn('text-[11px]', isLevelAfterFinalize(step.order, lv.order) && 'opacity-50')}>
<div className="flex items-start gap-1.5">
<span className={cn(
'mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold',
lv.status === 'Done' && 'bg-emerald-500 text-white',
lv.status === 'Current' && 'bg-brand-600 text-white',
lv.status === 'Pending' && 'bg-slate-200 text-slate-500',
)}>
{lvIcon}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-700">
{lv.name || `Cấp ${lv.order}`}
{lv.status === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{lv.status === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
{/* [S89 anh Kiệt FDC] Cấp này duyệt = KẾT THÚC quy trình, duyệt THAY CEO. */}
{lv.allowApproverFinalize && (
<span
className="ml-1.5 inline-flex items-center rounded bg-violet-100 px-1.5 py-0.5 text-[9px] font-semibold text-violet-800"
title="Cấp này duyệt là KẾT THÚC quy trình — duyệt THAY CEO, không trình lên Ban Giám đốc"
>
Duyệt thay CEO
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-slate-500">
{lv.approvers.length === 0
? '(chưa cấu hình)'
: lv.approvers.map((a, i) => (
<span key={a.userId} className="inline-flex items-center gap-1">
{i > 0 && <span className="text-slate-300">/</span>}
<span>{a.fullName}</span>
{/* [S76] Badge quyền nhập/điều chỉnh ngân sách (hiển thị-only, suy từ role) */}
{a.canEditProBudget && (
<span className="rounded bg-amber-100 px-1 py-0.5 text-[8px] font-semibold text-amber-700" title="Được nhập/điều chỉnh ngân sách cột PRO"> NS PRO</span>
)}
{a.canEditCcmBudget && (
<span className="rounded bg-sky-100 px-1 py-0.5 text-[8px] font-semibold text-sky-700" title="Được nhập/điều chỉnh ngân sách cột CCM"> NS CCM</span>
)}
</span>
))}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ol>
)}
{/* Phiếu V1 legacy không có flow → fallback hiển thị phase summary đơn giản */}
{!flow && (
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
Phiếu này dùng quy trình workflow chi tiết không khả dụng.
</div>
)}
{/* 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 && (
<div className={cn(
'rounded border px-3 py-2 text-[11px]',
actorInV2Level
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: 'border-amber-300 bg-amber-50 text-amber-800',
)}>
<div className="font-medium">
Đang chờ Bước {evaluation.currentApproval.stepIndex + 1} ({evaluation.currentApproval.stepName})
{evaluation.currentApproval.stepDepartmentName && <> · {evaluation.currentApproval.stepDepartmentName}</>}
{' — '}Cấp {evaluation.currentApproval.levelOrder}
</div>
<div className="mt-0.5">
NV duyệt: {evaluation.currentApproval.approvers.map(a => a.fullName).join(' / ') || '(chưa có)'}
</div>
{actorInV2Level
? <div className="mt-0.5 font-medium"> Đến lượt bạn duyệt</div>
: <div className="mt-0.5"> Không phải lượt bạn chỉ NV trên mới duyệt đưc cấp này</div>}
</div>
)}
{next.length > 0 && !readOnly && (
<div>
<Label className="text-xs">Hành đng:</Label>
<div className="mt-1 flex flex-wrap gap-1.5">
{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 (
<button
key={p}
onClick={() => !isDisabled && setTarget(p)}
disabled={isDisabled}
title={title}
className={cn(
'rounded border px-2 py-1 text-[11px] font-bold transition',
isDisabled && 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400 hover:bg-slate-50',
!isDisabled && isForwardApprove && 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
!isDisabled && isSendBack && 'border-amber-300 text-amber-700 hover:bg-amber-50',
!isDisabled && isCancel && 'border-red-300 text-red-700 hover:bg-red-50',
)}
>
{label}
</button>
)
})}
</div>
</div>
)}
{readOnly && next.length > 0 && (
<div className="rounded border border-dashed border-slate-200 px-3 py-2 text-[11px] text-slate-500">
Vào menu &ldquo;Duyệt&rdquo; đ chuyển phase.
</div>
)}
{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 (
<Dialog
open
onClose={() => { setTarget(null); setApproveFiles([]) }}
title={dialogTitle}
footer={<>
<Button variant="ghost" onClick={() => { setTarget(null); setApproveFiles([]) }}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending || priceMissing}>Xác nhận</Button>
</>}
>
{isCancel && (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
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.
</div>
)}
{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) && (
<div className="mb-3 space-y-1.5">
<Label className="text-[12px]">Chọn cách Trả lại</Label>
<div className="space-y-1">
{(levelOptions?.allowReturnOneLevel) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.OneLevel}
onChange={() => setReturnMode(WorkflowReturnMode.OneLevel)}
/>
<span>
<span className="font-medium">Trả về 1 Cấp trước</span>
<span className="block text-[10px] text-slate-500">Lùi 1 Cấp trong cùng Bước. NV cấp trước nhận lại.</span>
</span>
</label>
)}
{(levelOptions?.allowReturnOneStep) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.OneStep}
onChange={() => setReturnMode(WorkflowReturnMode.OneStep)}
/>
<span>
<span className="font-medium">Trả về 1 Bước trước</span>
<span className="block text-[10px] text-slate-500">Lùi sang Bước trước, NV Cấp cuối Bước đó nhận lại.</span>
</span>
</label>
)}
{(levelOptions?.allowReturnToAssignee) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.Assignee}
onChange={() => setReturnMode(WorkflowReturnMode.Assignee)}
/>
<span className="flex-1">
<span className="font-medium">Trả về Người chỉ đnh</span>
<span className="block text-[10px] text-slate-500">Pick từ list NV đã duyệt trước đó. Workflow set Cấp/Bước của NV.</span>
{returnMode === WorkflowReturnMode.Assignee && (
<select
className="mt-1.5 w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
value={returnTargetUserId ?? ''}
onChange={e => setReturnTargetUserId(e.target.value || null)}
>
<option value=""> Chọn NV </option>
{signedApprovers.map(a => (
<option key={a.userId} value={a.userId}>{a.fullName}</option>
))}
</select>
)}
</span>
</label>
)}
{(levelOptions?.allowReturnToDrafter !== false) && (
<label className="flex items-start gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-[12px] hover:bg-amber-50/40">
<input
type="radio"
className="mt-0.5"
checked={returnMode === WorkflowReturnMode.Drafter}
onChange={() => setReturnMode(WorkflowReturnMode.Drafter)}
/>
<span>
<span className="font-medium">Trả về Người soạn thảo (mặc đnh)</span>
<span className="block text-[10px] text-slate-500">Phase "Cần chỉnh sửa lại". Drafter sửa rồi gửi lại chạy từ Cấp 1 Bước 1.</span>
</span>
</label>
)}
</div>
</div>
)}
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
{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.'}
</div>
</>
)}
{/* 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 && (
<div className="mb-3">
<label className="flex cursor-pointer items-start gap-2 rounded border border-violet-200 bg-violet-50 px-3 py-2 text-[12px] text-violet-800 hover:bg-violet-100/60">
<input
type="checkbox"
className="mt-0.5"
checked={skipToFinalApprover}
onChange={e => setSkipToFinalApprover(e.target.checked)}
/>
<span>
<span className="font-medium">Duyệt thẳng Cấp cuối (skip Bước/Cấp trung gian)</span>
<span className="mt-0.5 block text-[11px] text-violet-700/80">
Phiếu sẽ skip tới NV cuối (CEO/cấp cuối) NV cuối vẫn cần duyệt thật đ hoàn tất.
</span>
</span>
</label>
</div>
)}
{!isCancel && !isSendBack && skipToFinalApprover && (
<div className="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
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 duyệt thật đ phiếu thành "Đã duyệt".
</div>
)}
{/* [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 && (
<div className="mb-3">
<label className="flex cursor-pointer items-start gap-2 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800 hover:bg-emerald-100/60">
<input
type="checkbox"
className="mt-0.5"
checked={finalizeByCcm}
onChange={e => setFinalizeByCcm(e.target.checked)}
/>
<span>
<span className="font-medium">Duyệt done, miễn CEO (CEO uỷ quyền cho TP CCM)</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
Gói {fmtMoney(evaluation.winnerQuoteTotal)}đ &lt; ngưỡng CEO {fmtMoney(evaluation.ceoApprovalThreshold ?? 0)}đ tích đ hoàn tất ngay, không cần trình CEO.
</span>
</span>
</label>
</div>
)}
{/* [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 && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<span className="font-medium"> Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
Đưc cấu hình sẵn trong Quy trình duyệt bạn duyệt xong 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.
</span>
</div>
)}
{/* [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 && (
<div className="mb-3 space-y-1.5">
<Label className="text-[12px]">
Chọn giá chốt (duyệt theo giá đ xuất) <span className="text-red-500">*</span>
</Label>
<div className="space-y-1">
{priceCandidates.map(c => (
<label key={c.source} className="flex items-center gap-2 rounded border border-brand-200 bg-white px-2 py-1.5 text-[12px] hover:bg-brand-50/40">
<input
type="radio"
checked={approvedPriceSource === c.source}
onChange={() => setApprovedPriceSource(c.source)}
/>
<span className="flex-1 text-slate-700">{c.label}</span>
<span className="font-mono font-semibold text-slate-900">{fmtMoney(c.amount)}đ</span>
</label>
))}
{priceCandidates.length === 0 && (
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
Chưa 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.
</div>
)}
</div>
</div>
)}
{/* [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 && (
<div className="mb-3">
<Label className="text-[12px]">Đính kèm file (tùy chọn)</Label>
<p className="mb-1.5 mt-0.5 text-[11px] text-slate-500">
Tải file của bạn lên kèm khi duyệt dùng khi thay đi nhỏ, không cần Trả lại phiếu.
</p>
<input
ref={approveFileInputRef}
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
onChange={e => {
const picked = Array.from(e.target.files ?? [])
e.target.value = ''
if (picked.length) setApproveFiles(prev => [...prev, ...picked])
}}
className="hidden"
/>
<button
type="button"
onClick={() => approveFileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 rounded border border-dashed border-brand-300 bg-brand-50/50 px-3 py-1.5 text-[11px] font-medium text-brand-700 hover:border-brand-500 hover:bg-brand-50"
>
<Upload className="h-3.5 w-3.5" /> + Chọn file đính kèm
</button>
{approveFiles.length > 0 && (
<ul className="mt-1.5 space-y-1">
{approveFiles.map((f, i) => (
<li key={i} className="flex items-center gap-1.5 rounded bg-slate-50 px-2 py-1 text-[11px]">
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<span className="min-w-0 flex-1 truncate text-slate-700" title={f.name}>{f.name}</span>
<span className="shrink-0 text-[10px] text-slate-400">{fmtFileSize(f.size)}</span>
<button
type="button"
onClick={() => setApproveFiles(prev => prev.filter((_, j) => j !== i))}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Bỏ file"
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
)}
</div>
)}
<Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
</Dialog>
)
})()}
{deptApprovals.length > 0 && (
<div className="border-t border-slate-200 pt-4">
<DeptApprovalsSection rows={deptApprovals} currentPhase={evaluation.phase} />
</div>
)}
{/* [S78] File do người duyệt đính kèm trong quá trình duyệt — hiển thị cho mọi
người xem phiếu (KHÔNG lẫn vào "Bảng so sánh" — xem filter PeDetailTabs). */}
{approvalAttachments.length > 0 && (
<div className="border-t border-slate-200 pt-4">
<h3 className="text-sm font-semibold text-slate-900">📎 File đính kèm khi duyệt</h3>
<p className="mt-0.5 text-[11px] text-slate-500">File do người duyệt tải lên trong quá trình duyệt.</p>
<div className="mt-2 space-y-1">
{approvalAttachments.map(a => (
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>{a.fileName}</span>
<span className="shrink-0 text-[10px] text-slate-400">{fmtFileSize(a.fileSize)}</span>
{isPreviewable(a.fileName) && (
<button
onClick={() => setPreviewAtt(a)}
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
title="Xem trước"
>
<Eye className="h-3 w-3" />
</button>
)}
<button
onClick={() => downloadAttachment(a)}
className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50"
title="Tải xuống"
>
<Download className="h-3 w-3" />
</button>
</div>
))}
</div>
{previewAtt && (
<AttachmentPreviewDialog
open
evaluationId={evaluation.id}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
</div>
)}
<div className="border-t border-slate-200 pt-4">
<PeApprovalsSection ev={evaluation} />
</div>
<div className="border-t border-slate-200 pt-4">
<PeHistorySection ev={evaluation} />
</div>
</div>
)
}
// 2-stage dept approval timeline (Migration 16). Group by phase × dept,
// show Review NV row + Confirm TPB row. Highlight pending khi current phase
// có Review nhưng chưa Confirm → user biết "đang chờ TPB confirm".
function DeptApprovalsSection({
rows,
currentPhase,
}: {
rows: PeDepartmentApproval[]
currentPhase: number
}) {
// Group: phase → dept → stages
const grouped = new Map<number, Map<string, PeDepartmentApproval[]>>()
for (const r of rows) {
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
const byDept = grouped.get(r.phaseAtApproval)!
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
byDept.get(r.departmentId)!.push(r)
}
// Order: phase asc
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
return (
<div>
<h3 className="text-sm font-semibold text-slate-900">Tiến trình duyệt 2-cấp phòng ban</h3>
<p className="mt-0.5 text-[11px] text-slate-500">NV Review TPB Confirm. Phase chỉ chuyển khi Confirm.</p>
<div className="mt-2 space-y-3">
{phaseOrder.map(phase => {
const byDept = grouped.get(phase)!
return (
<div key={phase}>
<div className={cn(
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
PurchaseEvaluationPhaseColor[phase],
)}>
{PurchaseEvaluationPhaseLabel[phase]}
</div>
<div className="mt-1 space-y-1.5">
{[...byDept.entries()].map(([deptId, stages]) => {
const review = stages.find(s => s.stage === ApprovalStage.Review)
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
const isPending = phase === currentPhase && review && !confirm
return (
<div key={deptId} className={cn(
'rounded border px-2 py-1.5 text-[11px]',
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
)}>
<div className="font-medium text-slate-700">{deptName}</div>
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
<span className="text-slate-500">Review:</span>
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
{review
? <> {review.approverName} <span className="text-slate-500"> {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
: '— chưa có'}
</span>
<span className="text-slate-500">Confirm:</span>
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
{confirm
? <> {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500"> {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
: '⏳ chờ TPB confirm'}
</span>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
}
function fmtTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function fmtMoney(n: number): string {
return n.toLocaleString('vi-VN')
}