[CLAUDE] PE: upload file dinh kem per-NCC (doi chieu bao gia)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m9s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m9s
User request: 'cho cac NCC 1,2 va 3 thi cho them cho upload file dinh
kem cho tung NCC de co the doi chieu'.
Entity PurchaseEvaluationAttachment + PurchaseEvaluationSupplierId nullable
da thiet ke san tu migration 12 — gio wire up BE + FE.
BE (Application/Api):
- PurchaseEvaluationAttachmentFeatures: Upload (multipart + supplierRowId
optional) + Download + Delete. Reuse IFileStorage + LocalFileStorage.
Validator 20MB + MIME whitelist (pdf/doc/docx/xls/xlsx/png/jpg/webp).
- Upload log vao PurchaseEvaluationChangelogs (Attachment + Insert).
- PurchaseEvaluationAttachmentDto + them field Attachments vao bundle.
- GetPurchaseEvaluationQueryHandler Include(x => x.Attachments) +
OrderByDescending(a => a.CreatedAt) projection.
- PurchaseEvaluationsController 3 endpoint:
POST /attachments (IFormFile + [FromForm] supplierRowId/purpose/note)
GET /attachments/{attId}/download (File stream)
DELETE /attachments/{attId}
- Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
FE (fe-admin + fe-user):
- Type PeAttachment + PeAttachmentPurpose/Label (QuoteDocument default)
- PeDetailBundle.attachments: PeAttachment[]
- SuppliersTab thay column Hien thi + Ghi chu bang column File dinh kem
(per-NCC upload + list N attachments + download + delete).
- SupplierAttachmentsCell component: <input type=file> hidden + [+ Them
file] button + inline list attachments voi Paperclip icon + filename
(click tai ve) + size + purpose chip + Trash2 delete.
This commit is contained in:
@ -2,11 +2,11 @@
|
||||
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
||||
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||||
// → PeApprovalsSection + PeHistorySection).
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Check, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
@ -16,10 +16,13 @@ import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
PeAttachmentPurpose,
|
||||
PeAttachmentPurposeLabel,
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
PurchaseEvaluationTypeLabel,
|
||||
type PeAttachment,
|
||||
type PeChangelog,
|
||||
type PeDetailBundle,
|
||||
type PeDetailRow,
|
||||
@ -265,25 +268,33 @@ function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
|
||||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">NCC</th>
|
||||
<th className="px-3 py-2 text-left">Hiển thị</th>
|
||||
<th className="px-3 py-2 text-left">Liên hệ</th>
|
||||
<th className="px-3 py-2 text-left">Điều khoản TT</th>
|
||||
<th className="px-3 py-2 text-left">Ghi chú</th>
|
||||
<th className="px-3 py-2 text-left">File đính kèm</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{ev.suppliers.map(s => (
|
||||
<tr key={s.id} className={cn(ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
|
||||
<td className="px-3 py-2 font-medium text-slate-900">{s.supplierName}</td>
|
||||
<td className="px-3 py-2">{s.displayName ?? '—'}</td>
|
||||
<tr key={s.id} className={cn('align-top', ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-slate-900">{s.supplierName}</div>
|
||||
{s.displayName && <div className="text-[11px] text-slate-500">{s.displayName}</div>}
|
||||
{s.note && <div className="mt-0.5 text-[11px] text-amber-600">{s.note}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[12px] text-slate-600">
|
||||
{s.contactName && <div>{s.contactName}</div>}
|
||||
{s.contactPhone && <div>{s.contactPhone}</div>}
|
||||
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-[12px] text-slate-600">{s.note ?? '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<SupplierAttachmentsCell
|
||||
evaluationId={ev.id}
|
||||
supplierRowId={s.id}
|
||||
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
@ -709,3 +720,121 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) {
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Cell upload file đính kèm per-NCC =====
|
||||
// 1 row = 1 NCC. User upload file báo giá (purpose=QuoteDocument mặc định) →
|
||||
// POST multipart với supplierRowId. List N file hiện có + Download/Delete inline.
|
||||
// Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
|
||||
function SupplierAttachmentsCell({
|
||||
evaluationId,
|
||||
supplierRowId,
|
||||
attachments,
|
||||
}: {
|
||||
evaluationId: string
|
||||
supplierRowId: string
|
||||
attachments: PeAttachment[]
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('supplierRowId', supplierRowId)
|
||||
fd.append('purpose', String(PeAttachmentPurpose.QuoteDocument))
|
||||
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã tải lên.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: async (attId: string) =>
|
||||
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã xóa.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
async function download(att: PeAttachment) {
|
||||
try {
|
||||
const res = await api.get(
|
||||
`/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
const url = window.URL.createObjectURL(res.data as Blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = att.fileName
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e))
|
||||
}
|
||||
}
|
||||
|
||||
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) upload.mutate(f)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const fmtSize = (b: number) =>
|
||||
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{attachments.length === 0 && (
|
||||
<div className="text-[11px] italic text-slate-400">Chưa có file</div>
|
||||
)}
|
||||
{attachments.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" />
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
|
||||
title={a.fileName}
|
||||
>
|
||||
{a.fileName}
|
||||
</button>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(a.fileSize)}</span>
|
||||
<span className="shrink-0 rounded bg-slate-200 px-1 text-[9px] text-slate-600">
|
||||
{PeAttachmentPurposeLabel[a.purpose] ?? ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
|
||||
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
|
||||
title="Xóa"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
||||
onChange={onPick}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={upload.isPending}
|
||||
className="inline-flex items-center gap-1 rounded border border-dashed border-slate-300 px-2 py-0.5 text-[11px] text-slate-500 hover:border-brand-300 hover:text-brand-700 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
{upload.isPending ? 'Đang tải…' : '+ Thêm file'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user