[CLAUDE] PurchaseEvaluation: nguoi duyet dinh kem file khi DUYET (reuse attachment Purpose=ApprovalAttachment, no migration)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m56s

Modal Duyet them picker multi-file -> upload TRUOC khi chuyen phase (file loi/>20MB = throw, khong duyet). File hien o muc 'File dinh kem khi duyet' (download/preview) trong Panel quy trinh, gan ten nguoi tai + thoi gian. Fix 2 filter dung supplierId=null lam proxy Bang-so-sanh (banSoSanhAttachments + submit-guard) loai purpose=5 tranh lan section + false-pass. FE 2 app SHA-identical. authz: approver upload duoc (handler khong guard drafter-only). Test 354 PASS, no migration. UAT Tra Sol / 5 tester.
This commit is contained in:
pqhuy1987
2026-06-19 16:46:37 +07:00
parent 095fb492cd
commit 7886fd03dd
7 changed files with 313 additions and 14 deletions

View File

@ -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 (

View File

@ -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<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
@ -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 (
<div className="space-y-4">
<div>
@ -390,10 +438,10 @@ export function PeWorkflowPanel({
return (
<Dialog
open
onClose={() => setTarget(null)}
onClose={() => { setTarget(null); setApproveFiles([]) }}
title={dialogTitle}
footer={<>
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
<Button variant="ghost" onClick={() => { setTarget(null); setApproveFiles([]) }}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending || priceMissing}>Xác nhận</Button>
</>}
>
@ -567,6 +615,54 @@ export function PeWorkflowPanel({
</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>
@ -579,6 +675,49 @@ export function PeWorkflowPanel({
</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>