[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) {
|
if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) {
|
||||||
missing.push("Chưa nhập Ngân sách kỳ này")
|
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)
|
// 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3).
|
||||||
if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) {
|
// 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")
|
missing.push("Chưa đính kèm Bảng so sánh")
|
||||||
}
|
}
|
||||||
return missing
|
return missing
|
||||||
@ -1743,9 +1749,11 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
|
|||||||
: null
|
: null
|
||||||
const giaChaoThau = computeGiaChaoThau(ev)
|
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(
|
const banSoSanhAttachments = ev.attachments.filter(
|
||||||
a => a.purchaseEvaluationSupplierId === null,
|
a => a.purchaseEvaluationSupplierId === null
|
||||||
|
&& a.purpose !== PeAttachmentPurpose.ApprovalAttachment,
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -2,9 +2,10 @@
|
|||||||
// Pulls nextPhases từ BE bundle (single source of truth) → render per-phase
|
// Pulls nextPhases từ BE bundle (single source of truth) → render per-phase
|
||||||
// action button. Approvals + History moved here from PeDetailTabs (2 section
|
// 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).
|
// 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Download, Eye, Paperclip, Upload, X } from 'lucide-react'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
@ -15,13 +16,16 @@ import { cn } from '@/lib/cn'
|
|||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import {
|
import {
|
||||||
ApprovalStage,
|
ApprovalStage,
|
||||||
|
PeAttachmentPurpose,
|
||||||
PurchaseEvaluationPhase,
|
PurchaseEvaluationPhase,
|
||||||
PurchaseEvaluationPhaseColor,
|
PurchaseEvaluationPhaseColor,
|
||||||
PurchaseEvaluationPhaseLabel,
|
PurchaseEvaluationPhaseLabel,
|
||||||
WorkflowReturnMode,
|
WorkflowReturnMode,
|
||||||
|
type PeAttachment,
|
||||||
type PeDepartmentApproval,
|
type PeDepartmentApproval,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
} from '@/types/purchaseEvaluation'
|
} from '@/types/purchaseEvaluation'
|
||||||
|
import { AttachmentPreviewDialog } from './AttachmentPreviewDialog'
|
||||||
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
||||||
|
|
||||||
export function PeWorkflowPanel({
|
export function PeWorkflowPanel({
|
||||||
@ -52,6 +56,11 @@ export function PeWorkflowPanel({
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
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).
|
// Mig 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại).
|
||||||
const levelOptions = evaluation.currentLevelOptions
|
const levelOptions = evaluation.currentLevelOptions
|
||||||
@ -142,6 +151,20 @@ export function PeWorkflowPanel({
|
|||||||
&& evaluation.phase !== 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.
|
// [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)
|
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`, {
|
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||||
targetPhase: target,
|
targetPhase: target,
|
||||||
decision: isReject ? 2 : 1,
|
decision: isReject ? 2 : 1,
|
||||||
@ -175,6 +198,7 @@ export function PeWorkflowPanel({
|
|||||||
setSkipToFinalApprover(false)
|
setSkipToFinalApprover(false)
|
||||||
setFinalizeByCcm(false)
|
setFinalizeByCcm(false)
|
||||||
setApprovedPriceSource(null)
|
setApprovedPriceSource(null)
|
||||||
|
setApproveFiles([])
|
||||||
if (!wasReject) onApproved?.()
|
if (!wasReject) onApproved?.()
|
||||||
},
|
},
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
@ -186,6 +210,30 @@ export function PeWorkflowPanel({
|
|||||||
const next = evaluation.workflow.nextPhases.filter(p => p !== PurchaseEvaluationPhase.TuChoi)
|
const next = evaluation.workflow.nextPhases.filter(p => p !== PurchaseEvaluationPhase.TuChoi)
|
||||||
const flow = evaluation.approvalFlow
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@ -390,10 +438,10 @@ export function PeWorkflowPanel({
|
|||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
onClose={() => setTarget(null)}
|
onClose={() => { setTarget(null); setApproveFiles([]) }}
|
||||||
title={dialogTitle}
|
title={dialogTitle}
|
||||||
footer={<>
|
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>
|
<Button onClick={() => transition.mutate()} disabled={transition.isPending || priceMissing}>Xác nhận</Button>
|
||||||
</>}
|
</>}
|
||||||
>
|
>
|
||||||
@ -567,6 +615,54 @@ export function PeWorkflowPanel({
|
|||||||
</div>
|
</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>
|
<Label>Ghi chú (tùy chọn)</Label>
|
||||||
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@ -579,6 +675,49 @@ export function PeWorkflowPanel({
|
|||||||
</div>
|
</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">
|
<div className="border-t border-slate-200 pt-4">
|
||||||
<PeApprovalsSection ev={evaluation} />
|
<PeApprovalsSection ev={evaluation} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -196,6 +196,7 @@ export const PeAttachmentPurpose = {
|
|||||||
RequirementSpec: 2,
|
RequirementSpec: 2,
|
||||||
DecisionExport: 3,
|
DecisionExport: 3,
|
||||||
ComparisonTable: 4,
|
ComparisonTable: 4,
|
||||||
|
ApprovalAttachment: 5,
|
||||||
Other: 99,
|
Other: 99,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@ -204,6 +205,7 @@ export const PeAttachmentPurposeLabel: Record<number, string> = {
|
|||||||
2: 'Yêu cầu KT',
|
2: 'Yêu cầu KT',
|
||||||
3: 'Phiếu duyệt',
|
3: 'Phiếu duyệt',
|
||||||
4: 'Bảng so sánh',
|
4: 'Bảng so sánh',
|
||||||
|
5: 'File khi duyệt',
|
||||||
99: 'Khác',
|
99: 'Khác',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -206,8 +206,14 @@ export function PeDetailTabs({
|
|||||||
if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) {
|
if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) {
|
||||||
missing.push("Chưa nhập Ngân sách kỳ này")
|
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)
|
// 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3).
|
||||||
if (!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null)) {
|
// 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")
|
missing.push("Chưa đính kèm Bảng so sánh")
|
||||||
}
|
}
|
||||||
return missing
|
return missing
|
||||||
@ -1743,9 +1749,11 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
|
|||||||
: null
|
: null
|
||||||
const giaChaoThau = computeGiaChaoThau(ev)
|
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(
|
const banSoSanhAttachments = ev.attachments.filter(
|
||||||
a => a.purchaseEvaluationSupplierId === null,
|
a => a.purchaseEvaluationSupplierId === null
|
||||||
|
&& a.purpose !== PeAttachmentPurpose.ApprovalAttachment,
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -2,9 +2,10 @@
|
|||||||
// Pulls nextPhases từ BE bundle (single source of truth) → render per-phase
|
// Pulls nextPhases từ BE bundle (single source of truth) → render per-phase
|
||||||
// action button. Approvals + History moved here from PeDetailTabs (2 section
|
// 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).
|
// 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Download, Eye, Paperclip, Upload, X } from 'lucide-react'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
@ -15,13 +16,16 @@ import { cn } from '@/lib/cn'
|
|||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import {
|
import {
|
||||||
ApprovalStage,
|
ApprovalStage,
|
||||||
|
PeAttachmentPurpose,
|
||||||
PurchaseEvaluationPhase,
|
PurchaseEvaluationPhase,
|
||||||
PurchaseEvaluationPhaseColor,
|
PurchaseEvaluationPhaseColor,
|
||||||
PurchaseEvaluationPhaseLabel,
|
PurchaseEvaluationPhaseLabel,
|
||||||
WorkflowReturnMode,
|
WorkflowReturnMode,
|
||||||
|
type PeAttachment,
|
||||||
type PeDepartmentApproval,
|
type PeDepartmentApproval,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
} from '@/types/purchaseEvaluation'
|
} from '@/types/purchaseEvaluation'
|
||||||
|
import { AttachmentPreviewDialog } from './AttachmentPreviewDialog'
|
||||||
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
import { PeApprovalsSection, PeHistorySection } from './PeDetailTabs'
|
||||||
|
|
||||||
export function PeWorkflowPanel({
|
export function PeWorkflowPanel({
|
||||||
@ -52,6 +56,11 @@ export function PeWorkflowPanel({
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
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).
|
// Mig 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại).
|
||||||
const levelOptions = evaluation.currentLevelOptions
|
const levelOptions = evaluation.currentLevelOptions
|
||||||
@ -142,6 +151,20 @@ export function PeWorkflowPanel({
|
|||||||
&& evaluation.phase !== 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.
|
// [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)
|
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`, {
|
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||||
targetPhase: target,
|
targetPhase: target,
|
||||||
decision: isReject ? 2 : 1,
|
decision: isReject ? 2 : 1,
|
||||||
@ -175,6 +198,7 @@ export function PeWorkflowPanel({
|
|||||||
setSkipToFinalApprover(false)
|
setSkipToFinalApprover(false)
|
||||||
setFinalizeByCcm(false)
|
setFinalizeByCcm(false)
|
||||||
setApprovedPriceSource(null)
|
setApprovedPriceSource(null)
|
||||||
|
setApproveFiles([])
|
||||||
if (!wasReject) onApproved?.()
|
if (!wasReject) onApproved?.()
|
||||||
},
|
},
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
@ -186,6 +210,30 @@ export function PeWorkflowPanel({
|
|||||||
const next = evaluation.workflow.nextPhases.filter(p => p !== PurchaseEvaluationPhase.TuChoi)
|
const next = evaluation.workflow.nextPhases.filter(p => p !== PurchaseEvaluationPhase.TuChoi)
|
||||||
const flow = evaluation.approvalFlow
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@ -390,10 +438,10 @@ export function PeWorkflowPanel({
|
|||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
onClose={() => setTarget(null)}
|
onClose={() => { setTarget(null); setApproveFiles([]) }}
|
||||||
title={dialogTitle}
|
title={dialogTitle}
|
||||||
footer={<>
|
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>
|
<Button onClick={() => transition.mutate()} disabled={transition.isPending || priceMissing}>Xác nhận</Button>
|
||||||
</>}
|
</>}
|
||||||
>
|
>
|
||||||
@ -567,6 +615,54 @@ export function PeWorkflowPanel({
|
|||||||
</div>
|
</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>
|
<Label>Ghi chú (tùy chọn)</Label>
|
||||||
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@ -579,6 +675,49 @@ export function PeWorkflowPanel({
|
|||||||
</div>
|
</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">
|
<div className="border-t border-slate-200 pt-4">
|
||||||
<PeApprovalsSection ev={evaluation} />
|
<PeApprovalsSection ev={evaluation} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -200,6 +200,7 @@ export const PeAttachmentPurpose = {
|
|||||||
RequirementSpec: 2,
|
RequirementSpec: 2,
|
||||||
DecisionExport: 3,
|
DecisionExport: 3,
|
||||||
ComparisonTable: 4,
|
ComparisonTable: 4,
|
||||||
|
ApprovalAttachment: 5,
|
||||||
Other: 99,
|
Other: 99,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@ -208,6 +209,7 @@ export const PeAttachmentPurposeLabel: Record<number, string> = {
|
|||||||
2: 'Yêu cầu KT',
|
2: 'Yêu cầu KT',
|
||||||
3: 'Phiếu duyệt',
|
3: 'Phiếu duyệt',
|
||||||
4: 'Bảng so sánh',
|
4: 'Bảng so sánh',
|
||||||
|
5: 'File khi duyệt',
|
||||||
99: 'Khác',
|
99: 'Khác',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ public enum PurchaseEvaluationAttachmentPurpose
|
|||||||
RequirementSpec = 2, // Bản vẽ/yêu cầu kỹ thuật kèm theo
|
RequirementSpec = 2, // Bản vẽ/yêu cầu kỹ thuật kèm theo
|
||||||
DecisionExport = 3, // Bản phiếu duyệt đã export
|
DecisionExport = 3, // Bản phiếu duyệt đã export
|
||||||
ComparisonTable = 4, // Bảng so sánh tổng — không gắn với 1 NCC, file tổng hợp
|
ComparisonTable = 4, // Bảng so sánh tổng — không gắn với 1 NCC, file tổng hợp
|
||||||
|
ApprovalAttachment = 5, // S78 — file người DUYỆT tự đính kèm khi duyệt (thay đổi nhỏ, không muốn Trả lại). supplierId=null, uploader=CreatedBy
|
||||||
Other = 99,
|
Other = 99,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user