All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m2s
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>
881 lines
50 KiB
TypeScript
881 lines
50 KiB
TypeScript
// 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} là 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 cũ — 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 “Duyệt” để 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 ký 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 ký 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)}đ < 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 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.
|
||
</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 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.
|
||
</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 có 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 có 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')
|
||
}
|
||
|