[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
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:
@ -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 (
|
||||
|
||||
@ -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 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>
|
||||
@ -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>
|
||||
|
||||
@ -200,6 +200,7 @@ export const PeAttachmentPurpose = {
|
||||
RequirementSpec: 2,
|
||||
DecisionExport: 3,
|
||||
ComparisonTable: 4,
|
||||
ApprovalAttachment: 5,
|
||||
Other: 99,
|
||||
} as const
|
||||
|
||||
@ -208,6 +209,7 @@ export const PeAttachmentPurposeLabel: Record<number, string> = {
|
||||
2: 'Yêu cầu KT',
|
||||
3: 'Phiếu duyệt',
|
||||
4: 'Bảng so sánh',
|
||||
5: 'File khi duyệt',
|
||||
99: 'Khác',
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user