diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 8c148e1..407c970 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -206,8 +206,14 @@ export function PeDetailTabs({ if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) { missing.push("Chưa nhập Ngân sách kỳ này") } - // 4. Chưa đính kèm Bảng so sánh (attachment với supplier-row null — chuẩn Section 3) - if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) { + // 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3). + // S78 — loại file "đính kèm khi duyệt" (purpose=ApprovalAttachment, supplierId=null) + // khỏi check: nó KHÔNG phải bảng so sánh, không được false-pass submit-guard khi + // phiếu Trả-lại re-submit (lúc đó đã tồn tại file khi-duyệt từ vòng trước). + if (!evaluation.attachments?.some( + a => a.purchaseEvaluationSupplierId === null + && a.purpose !== PeAttachmentPurpose.ApprovalAttachment, + )) { missing.push("Chưa đính kèm Bảng so sánh") } return missing @@ -1743,9 +1749,11 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly : null const giaChaoThau = computeGiaChaoThau(ev) - // d. Bản so sánh — attachments với purpose=ComparisonTable hoặc supplier-row null + // d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi + // duyệt" (S78 — purpose=ApprovalAttachment cũng supplierId=null) khỏi section này. const banSoSanhAttachments = ev.attachments.filter( - a => a.purchaseEvaluationSupplierId === null, + a => a.purchaseEvaluationSupplierId === null + && a.purpose !== PeAttachmentPurpose.ApprovalAttachment, ) return ( diff --git a/fe-admin/src/components/pe/PeWorkflowPanel.tsx b/fe-admin/src/components/pe/PeWorkflowPanel.tsx index 86c56e0..155ed90 100644 --- a/fe-admin/src/components/pe/PeWorkflowPanel.tsx +++ b/fe-admin/src/components/pe/PeWorkflowPanel.tsx @@ -2,9 +2,10 @@ // 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, useState } from 'react' +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' @@ -15,13 +16,16 @@ 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({ @@ -52,6 +56,11 @@ export function PeWorkflowPanel({ const qc = useQueryClient() const { user: currentUser } = useAuth() const isAdmin = currentUser?.roles?.includes('Admin') ?? false + // S78 — người duyệt đính kèm file khi DUYỆT. File chọn (chưa upload) staged ở state, + // upload trong transition.mutationFn TRƯỚC khi chuyển phase (file lỗi = không duyệt). + const [approveFiles, setApproveFiles] = useState([]) + const approveFileInputRef = useRef(null) + const [previewAtt, setPreviewAtt] = useState(null) // Mig 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại). const levelOptions = evaluation.currentLevelOptions @@ -142,6 +151,20 @@ export function PeWorkflowPanel({ && 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. const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm) + // [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, @@ -175,6 +198,7 @@ export function PeWorkflowPanel({ setSkipToFinalApprover(false) setFinalizeByCcm(false) setApprovedPriceSource(null) + setApproveFiles([]) if (!wasReject) onApproved?.() }, onError: e => toast.error(getErrorMessage(e)), @@ -186,6 +210,30 @@ export function PeWorkflowPanel({ 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 (
@@ -390,10 +438,10 @@ export function PeWorkflowPanel({ return ( setTarget(null)} + onClose={() => { setTarget(null); setApproveFiles([]) }} title={dialogTitle} footer={<> - + } > @@ -567,6 +615,54 @@ export function PeWorkflowPanel({
)} + {/* [S78 — UAT Tra Sol] Người duyệt đính kèm file khi DUYỆT — dùng khi có thay + đổi nhỏ, không muốn Trả lại phiếu. File upload TRƯỚC khi chuyển phase. */} + {isApproveAction && ( +
+ +

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

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