Files
solution-erp/fe-user/src/components/pe/PeWorkflowPanel.tsx
pqhuy1987 4464a464c5
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m47s
[CLAUDE] PurchaseEvaluation: nut XOA phieu o man duyet + menu 3 muc + man "Da xoa" (dot 2)
Dong ca UAT goc (Tra Sol): "lo bam sai cai goi thau, quay lai khong duoc, phai
xoa thi no moi ko co luy ke len". Dot 1 (b1bae77) da dung NEN (co F6 + lenh sua
quy trinh tai cho); dot 2 la phan nguoi dung cham vao.

- App: PeSoftDeleteFeatures.cs MOI
  - DELETE /api/purchase-evaluations/{id}/by-approver — 3 rao: phase ChoDuyet
    ^ dung luot actor ^ matchingLevel.AllowApproverDelete (PER-NGUOI, khong g.Any)
  - GET /api/purchase-evaluations/deleted — IgnoreQueryFilters() KEM .Where(IsDeleted)
    (IgnoreQueryFilters GO filter chu khong DAO — thieu Where la liet ke ca phieu song)
    + tai lap khoi IDOR cua list hien hanh
  - ghi vet ChangelogAction.Delete + ly do
- Api: 2 endpoint moi. 🔴 by-approver CO Y KHONG co [Authorize(Policy)] — do tren DB:
  moi vai dang lam nguoi duyet (Procurement/CostControl...) deu CanDelete=0 va khong
  seeder nao nang co do => gan policy la 403 voi dung nguoi can dung, ma FE gate cung
  quyen nen khong thay nut khong thay loi (gotcha #44). Quyen = 3 rao handler; co F6
  la admin tick dich danh tung nguoi => chat hon quyen theo vai.
- Dto: ApprovalWorkflowOptionsDto + AllowApproverDelete. Thieu field nay thi FE gate
  luon sai => nut KHONG BAO GIO hien, ma BE lan FE deu build sach (reviewer bat).
- Domain/Infra: 2 menu key Pe_{code}_Approved / _Deleted (KHONG vao MenuKeys.All =>
  giu 54/216) + seed tree + labelBackfill "Duyet" -> "Dang duyet" + 2 danh sach permission
- FE x2 app: nut Xoa trong PeWorkflowPanel (md5 mirror khop) + dialog ly do + Layout
  regex/route cho 2 muc moi + man "Da xoa" chi-xem
- Tests: +11 (562 tong, 0 fail). T27 khoa CA LOP loi entity-co-co-ma-DTO-khong-co
  (fault-inject xac nhan: go field ra => T27 do ngay)

Review: 12 FLAG, chot chan H1 (DTO thieu field) da va. Deploy KHONG co migration
nhung CO seed menu => phai restart API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:06:14 +07:00

1063 lines
62 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, Trash2, 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,
onDeleted,
}: {
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
/** [S155 đợt 2] gọi sau khi XÓA MỀM phiếu đang duyệt thành công — caller đóng
* detail/overlay về danh sách (phiếu vừa xóa không còn đọc được: global filter
* `PurchaseEvaluationConfiguration.cs:84` loại nó khỏi `GET /{id}` ⇒ giữ overlay
* sẽ hiện "Không tìm thấy phiếu"). KHÔNG dùng lại `onApproved` vì 2 việc khác nghĩa. */
onDeleted?: () => 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)
// [S97 2026-07-01 — anh Kiệt FDC] Cấp cấu hình KẾT THÚC (AllowApproverFinalize) = ô-tick OPT-IN:
// default UNCHECKED → trình tiếp CEO/cấp sau; TÍCH mới KẾT THÚC tại cấp này. Đảo opt-out S96.
const [applyLevelFinalize, setApplyLevelFinalize] = useState(false)
const [approvedPriceSource, setApprovedPriceSource] = useState<string | null>(null)
const qc = useQueryClient()
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
// [S155 đợt 2 — UAT Tra Sol 2026-07-27] XÓA MỀM phiếu khi ĐANG DUYỆT: "lỡ bấm sai
// cái gói thầu / quay lại không được / phải xóa thì nó mới ko có lũy kế lên".
// Dialog xác nhận RIÊNG (không trộn vào dialog transition) + ô lý do → changelog BE.
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteReason, setDeleteReason] = useState('')
// 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
// [S155 đợt 2] Rào hiển thị nút XÓA — dựng để khớp 1-1 với 3 rào BE
// (`PeSoftDeleteFeatures.cs:65 / :104 / :114`) + tầng authz độc lập (gotcha #82).
//
// 🔴 KHÔNG dùng `actorInV2Level` ở đây dù nó sẵn có: nó có `isAdmin ||` bypass, còn
// đường xóa BE CỐ Ý KHÔNG bypass admin (`:100-104` — bê fallback `.First()` sang sẽ
// biến "admin bất kỳ" thành người xóa được phiếu người khác). Tệ hơn, khi actor không
// match thì `currentLevelOptions` BE fallback về ROW ĐẦU của Cấp
// (`PurchaseEvaluationFeatures.cs:1084-1085`) ⇒ admin sẽ đọc cờ F6 CỦA NGƯỜI KHÁC,
// phá đúng ràng buộc PER-NGƯỜI owner chốt. Vậy dùng tập con CHẶT dưới đây.
const actorIsCurrentApprover = !!currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id)
// 🔴 Cờ F6 đọc từ `currentLevelOptions` — kênh DUY NHẤT per-NGƯỜI (BE resolve slot theo
// `ApproverUserId == currentUser` rồi mới đọc cờ). So `=== true` (fail-closed) CÓ CHỦ Ý:
// field vắng ⇒ ẩn nút, KHÔNG lòi nút xoá-chứng-từ-tài-chính rồi ăn 403 hàng loạt.
const canDeleteWhileApproving =
!readOnly
&& evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
&& actorIsCurrentApprover
&& levelOptions?.allowApproverDelete === true
// 🔴 CỐ Ý KHÔNG gate bằng `can(MenuKeys.PurchaseEvaluations, 'Delete')` — owner chốt
// 2026-07-27. Reviewer ĐO trên DB: mọi vai đang thật sự làm người duyệt (`Procurement`,
// `CostControl`, …) đều `CanDelete = 0`, và không seeder nào nâng cờ đó ⇒ gate bằng quyền
// theo vai sẽ **ẩn nút với đúng người có quyền**, im lặng, không lỗi (gotcha #44).
// Nguồn quyền = 3 rào ở trên, khớp 1-1 với 3 rào BE trong `PeSoftDeleteFeatures.cs`.
// Rào `allowApproverDelete` là cờ per-NGƯỜI admin tick đích danh — chặt hơn quyền theo vai.
// ⚠️ Endpoint BE cũng đã gỡ `[Authorize(Policy=...)]` cho khớp; đừng khôi phục MỘT bên.
// [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 để người xem phiếu biết quy trình DỪNG ở cấp này.
// [S97 anh Kiệt FDC] GATE theo endsBeforeCeo (runtime): phiếu đã đi HẾT lên CEO (approver opt-out
// finalize) → BE trả endsBeforeCeo=false → KHÔNG làm mờ, hiện đúng CEO đã duyệt (bug "CEO duyệt mà mờ").
const finalizeFlowStep = evaluation.endsBeforeCeo
? (evaluation.approvalFlow?.steps.find(s => s.levels.some(l => l.allowApproverFinalize)) ?? null)
: 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)
// [S134 anh Kiệt FDC] Phiếu trước cùng gói thầu CHƯA duyệt (informational nudge — KHÔNG
// hard-block). M2: banner "cần duyệt phiếu trước" chỉ có nghĩa khi ĐANG chờ duyệt (phase
// ChoDuyet); phiếu Nháp/TraLai/TuChoi cũng có pending fill nhưng banner vô nghĩa ở đó.
const pendingPriors = evaluation.budgetSummary?.pendingPriorPes ?? []
const showPendingBanner = pendingPriors.length > 0
&& !evaluation.budgetSummary?.budgetFrozen
&& !readOnly
&& evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
const pendingPriorsText = pendingPriors
.map(p => `${p.maPhieu ?? '(chưa có mã)'} (${PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`})`)
.join(', ')
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
// [S117 anh Kiệt FDC] Khi PRO có CẢ Min & Max → thêm ứng viên "duyệt theo khoảng"
// (source=ProMinMax, amount=Min, amountMax=Max). Chọn nó = chốt dải [Min, Max].
const hasBothProPrices = evaluation.proSuggestedMinPrice != null && evaluation.proSuggestedMaxPrice != 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 },
...(hasBothProPrices
? [{ source: 'ProMinMax', label: 'PRO — cả Min và Max (duyệt theo khoảng)', amount: evaluation.proSuggestedMinPrice, amountMax: evaluation.proSuggestedMaxPrice }]
: []),
{ source: 'Ccm', label: 'CCM đề xuất', amount: evaluation.ccmSuggestedPrice },
] as { source: string; label: string; amount: number | null | undefined; amountMax?: number | null }[])
.filter((c): c is { source: string; label: string; amount: number; amountMax?: number } => c.amount != null)
const selectedCandidate = priceCandidates.find(c => c.source === approvedPriceSource)
const selectedPriceAmount = selectedCandidate?.amount ?? null
const selectedPriceAmountMax = selectedCandidate?.amountMax ?? 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 && applyLevelFinalize))
// [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,
// [S96 — anh Kiệt FDC] cờ finalize opt-out: chỉ có ý nghĩa khi cấp cấu hình KẾT THÚC.
// Bỏ tick (false) → BE không finalize, trình tiếp CEO; cấp thường gửi true (no-op).
applyLevelFinalize: !isReject && (approverFinalizeEligible ? applyLevelFinalize : true),
approvedPriceAmount: sendPrice ? selectedPriceAmount : null,
approvedPriceSource: sendPrice ? approvedPriceSource : null,
// [S117] Duyệt theo khoảng: chỉ gửi Max khi nguồn = ProMinMax (selectedPriceAmountMax != null).
// Nguồn đơn → undefined → axios bỏ key (BE reject nếu source đơn mà kèm Max).
approvedPriceMaxAmount: sendPrice ? (selectedPriceAmountMax ?? undefined) : undefined,
})
},
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)
setApplyLevelFinalize(false)
setApprovedPriceSource(null)
setApproveFiles([])
if (!wasReject) onApproved?.()
},
onError: e => toast.error(getErrorMessage(e)),
})
// [S155 đợt 2] XÓA MỀM phiếu đang duyệt. Route CANONICAL `.../{id}/by-approver` —
// KHÁC hẳn đường xóa nháp `DELETE /purchase-evaluations/{id}` (PeDetailTabs, giữ nguyên).
// Lý do đi trong BODY: controller bind `[FromBody] DeleteByApproverBody? body`
// (`PurchaseEvaluationsController.cs`), record `(string? Reason)` ⇒ axios `data:`.
const deleteByApprover = useMutation({
mutationFn: async () =>
api.delete(`/purchase-evaluations/${evaluation.id}/by-approver`, {
data: { reason: deleteReason.trim() || null },
}),
onSuccess: () => {
toast.success('Đã xóa phiếu — chuyển sang mục "Đã xóa", thôi tính vào lũy kế.')
setDeleteOpen(false)
setDeleteReason('')
// Phiếu rời MỌI danh sách sống (global filter) + số lũy kế đổi ⇒ invalidate cả
// pe-budget (ma trận ngân sách S133/S134 đọc lũy kế) ngoài pe-list/pe-detail.
qc.invalidateQueries({ queryKey: ['pe-list'] })
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
qc.invalidateQueries({ queryKey: ['pe-budget'] })
onDeleted?.()
},
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 tại ${evaluation.finalizeLevelName ?? evaluation.finalizeStepName ?? 'cấp duyệt thay CEO'} phía trên — bước này không cần trình tới`}
>
Kết thúc tại {evaluation.finalizeLevelName ?? evaluation.finalizeStepName ?? 'cấp trên'}
</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>
)}
{/* [S134] Banner cảnh báo phiếu trước cùng gói thầu CHƯA duyệt (M2: chỉ khi phase
ChoDuyet). Informational — KHÔNG disable nút duyệt. */}
{showPendingBanner && (
<div className="rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
{pendingPriors.length} phiếu trước cùng gói thầu CHƯA duyệt: {pendingPriorsText} cần duyệt phiếu trước trước phiếu này.
</div>
)}
{(next.length > 0 || canDeleteWhileApproving) && !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>
)
})}
{/* [S155 đợt 2] Nút XÓA — CẠNH vòng `next.map`, CỐ Ý KHÔNG nằm TRONG map:
map sinh nút theo PHASE (`workflow.nextPhases`), còn Xóa KHÔNG phải một
phase — nhét vào map sẽ phải bịa 1 phase giả. Điều kiện hiện nút ở
`canDeleteWhileApproving` (4 rào). KHÔNG đụng nút xóa NHÁP ở
`PeDetailTabs.tsx:457-472` — đó là đường khác, endpoint khác, quyền khác. */}
{canDeleteWhileApproving && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
title="Xóa phiếu này (phiếu chuyển sang mục &ldquo;Đã xóa&rdquo; và thôi tính vào lũy kế)"
className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-[11px] font-bold text-red-700 transition hover:bg-red-50"
>
<Trash2 className="h-3 w-3" /> Xóa phiếu
</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 && applyLevelFinalize))
// [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>
</>}
>
{/* [S134] NHẮC LẠI phiếu trước chưa duyệt TRONG dialog duyệt (trên bộ chọn giá)
— người duyệt cuối chắc chắn thấy. KHÔNG đụng disable nút Xác nhận (B3). */}
{isApproveAction && showPendingBanner && (
<div className="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
{pendingPriors.length} phiếu trước cùng gói thầu CHƯA duyệt: {pendingPriorsText} cần duyệt phiếu trước trước phiếu này.
</div>
)}
{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>
)}
{/* [S96 2026-07-01 — anh Kiệt FDC] Cấp cấu hình KẾT THÚC trong Quy trình duyệt NAY là
ô-tích BỎ được (opt-out): default tick = duyệt xong KẾT THÚC (không lên CEO); bỏ tick
= vẫn trình tiếp CEO. Đảo S89 (auto-finalize im lặng → "cái này option thôi"). */}
{isApproveAction && approverFinalizeEligible && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<label className="flex cursor-pointer items-start gap-2">
<input
type="checkbox"
className="mt-0.5"
checked={applyLevelFinalize}
onChange={e => setApplyLevelFinalize(e.target.checked)}
/>
<span>
<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">
{applyLevelFinalize
? 'Đã tích — duyệt xong là phiếu "Đã duyệt", KẾT THÚC tại cấp này, KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới.'
: 'Mặc định: duyệt xong phiếu TRÌNH TIẾP lên CEO/cấp sau. TÍCH vào nếu muốn KẾT THÚC ngay tại cấp này (không lên CEO — không cần chọn giá chốt).'}
</span>
</span>
</label>
</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">
{c.amountMax != null ? `${fmtMoney(c.amount)}đ ${fmtMoney(c.amountMax)}đ` : `${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>
)
})()}
{/* [S155 đợt 2] Dialog xác nhận XÓA — RIÊNG, không trộn vào dialog transition ở trên
(transition đi `POST /transitions` với targetPhase; xóa đi `DELETE /by-approver`).
Nội dung nói thẳng HỆ QUẢ vì màn "Đã xóa" hiện là CHỈ XEM, chưa có khôi phục
(owner chốt (6)) ⇒ với người dùng đây là thao tác một chiều. */}
{deleteOpen && (
<Dialog
open
size="sm"
onClose={() => { setDeleteOpen(false); setDeleteReason('') }}
title="🗑 Xóa phiếu đang duyệt"
footer={<>
<Button variant="ghost" onClick={() => { setDeleteOpen(false); setDeleteReason('') }}>Hủy</Button>
<Button
variant="danger"
onClick={() => deleteByApprover.mutate()}
disabled={deleteByApprover.isPending}
>
{deleteByApprover.isPending ? 'Đang xóa…' : 'Xác nhận xóa'}
</Button>
</>}
>
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-800">
<div className="font-semibold">Xóa phiếu &ldquo;{evaluation.tenGoiThau}&rdquo;?</div>
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-[11px]">
<li>Phiếu <strong>rời khỏi quy trình duyệt</strong> chuyển sang mục <strong>&ldquo;Đã xóa&rdquo;</strong>.</li>
<li>Phiếu <strong>thôi tính vào lũy kế</strong> ngân sách của hạng mục đây do chính của nút này.</li>
<li>Mục &ldquo;Đã xóa&rdquo; hiện <strong>chỉ đ xem</strong>: chưa khôi phục. Cần lại thì phải tạo phiếu mới.</li>
<li>Thao tác đưc <strong>ghi vết</strong> (ai xóa, lúc nào, do) vào lịch sử phiếu.</li>
</ul>
</div>
<Label> do xóa (nên ghi )</Label>
<Textarea
value={deleteReason}
onChange={e => setDeleteReason(e.target.value)}
rows={3}
maxLength={2000}
placeholder="VD: chọn nhầm gói thầu — phiếu này sai hạng mục, sẽ tạo lại phiếu đúng."
/>
<p className="mt-1 text-[11px] text-slate-500">
do lưu vào lịch sử phiếu (tối đa 2000 tự). Bỏ trống vẫn xóa đưc, nhưng người
sau sẽ không biết sao phiếu biến mất.
</p>
</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')
}