[CLAUDE] App+Infra+Api+FE: Attachment upload E2E
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m40s

Foundation file-storage:
- IFileStorage interface (Application) — SaveAsync/OpenReadAsync/
  DeleteAsync/Exists. Future swap cho S3/Azure Blob không đổi caller.
- LocalFileStorage (Infrastructure) — resolve Uploads:RootPath từ
  config, path-traversal guard (resolved full path phải stay in root),
  tự tạo directory khi save.
- DI: singleton (stateless).
- Config: dev "uploads", prod "C:\inetpub\solution-erp\uploads".

CQRS:
- UploadContractAttachmentCommand: validate size <=20MB + MIME whitelist
  (pdf, doc/docx, xls/xlsx, png/jpg/jpeg/webp). Sanitize filename
  (strip path components + invalid FS chars + leading dots). Storage
  path: contracts/{contractId}/{attId}_{safeFileName}.
- DownloadContractAttachmentQuery: trả Stream + FileName + ContentType.
- DeleteContractAttachmentCommand: best-effort file delete sau DB remove
  (orphan cleanup job có thể sweep sau).

Api:
- POST /api/contracts/{id}/attachments — multipart/form-data, field
  'file' + form fields 'purpose' + 'note'. RequestSizeLimit 25MB
  (validator enforces 20MB).
- GET /api/contracts/{id}/attachments/{attId}/download — File() stream.
- DELETE /api/contracts/{id}/attachments/{attId}.

FE ContractAttachmentsSection (both apps, identical):
- Drag-drop zone với dragging highlight (brand-500 border + brand-50 bg)
- Purpose selector (DraftExport / ScannedSigned / SealedCopy / Other)
- List có icon per MIME (FileText/Image/File), filename, metadata
  (purpose · size · createdAt), download button (fetch blob + trigger
  browser save với auth header), delete button (confirm dialog)
- Empty state hint về use-case ("bản scan HĐ đã ký ở phase In ký…")

Integrated vào cả 2 ContractDetailPage — ngay dưới phần comments,
trước sidebar lịch sử duyệt.

Unblock E2E workflow: users giờ có thể upload bản scan ký (DangInKy),
scan đóng dấu (DangDongDau) — phase transitions có bằng chứng thật.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 20:37:35 +07:00
parent 346bd5d644
commit c8d0070770
11 changed files with 713 additions and 0 deletions

View File

@ -0,0 +1,224 @@
import { useRef, useState, type ChangeEvent, type DragEvent } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Paperclip, Upload, Download, Trash2, FileText, Image as ImageIcon, File as FileIcon } from 'lucide-react'
import { api, TOKEN_KEY } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { EmptyState } from '@/components/EmptyState'
import type { ContractAttachment } from '@/types/contracts'
import { cn } from '@/lib/cn'
const PurposeLabel: Record<number, string> = {
1: 'File gốc (draft export)',
2: 'Bản scan đã ký',
3: 'Bản scan đã đóng dấu',
99: 'Khác',
}
const PURPOSES = [1, 2, 3, 99] as const
function fmtSize(n: number) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
function iconFor(contentType: string) {
if (contentType.startsWith('image/')) return ImageIcon
if (contentType.includes('pdf') || contentType.includes('word') || contentType.includes('document'))
return FileText
return FileIcon
}
const BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '') + '/api'
export function ContractAttachmentsSection({
contractId,
attachments,
canEdit = true,
}: {
contractId: string
attachments: ContractAttachment[]
canEdit?: boolean
}) {
const qc = useQueryClient()
const inputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)
const [purpose, setPurpose] = useState<number>(2)
const upload = useMutation({
mutationFn: async (file: File) => {
const form = new FormData()
form.append('file', file)
form.append('purpose', String(purpose))
const res = await api.post(`/contracts/${contractId}/attachments`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return res.data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract', contractId] })
toast.success('Upload thành công')
},
onError: err => toast.error(`Upload lỗi: ${getErrorMessage(err)}`),
})
const del = useMutation({
mutationFn: async (attId: string) => api.delete(`/contracts/${contractId}/attachments/${attId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract', contractId] })
toast.success('Đã xóa')
},
onError: err => toast.error(`Xóa lỗi: ${getErrorMessage(err)}`),
})
function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
// Upload each file sequentially so the server respects its own rate limits
for (const f of Array.from(files)) upload.mutate(f)
}
function onDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
setDragging(false)
if (!canEdit) return
handleFiles(e.dataTransfer.files)
}
function onPick(e: ChangeEvent<HTMLInputElement>) {
handleFiles(e.target.files)
// Reset so same file can be re-picked if needed
e.target.value = ''
}
async function download(att: ContractAttachment) {
// Blob fetch via fetch API so we can include auth header + trigger browser save
const token = localStorage.getItem(TOKEN_KEY)
const res = await fetch(`${BASE_URL}/contracts/${contractId}/attachments/${att.id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) {
toast.error(`Tải xuống lỗi (HTTP ${res.status})`)
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
return (
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-semibold text-slate-700">
<Paperclip className="h-4 w-4" />
Tệp đính kèm ({attachments.length})
</h2>
{canEdit && (
<div className="flex items-center gap-2">
<Select value={purpose} onChange={e => setPurpose(Number(e.target.value))} className="h-8 w-48 text-xs">
{PURPOSES.map(p => (
<option key={p} value={p}>
{PurposeLabel[p]}
</option>
))}
</Select>
</div>
)}
</div>
{canEdit && (
<div
onDragOver={e => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={cn(
'mb-3 cursor-pointer rounded-lg border-2 border-dashed px-4 py-6 text-center transition',
dragging ? 'border-brand-500 bg-brand-50' : 'border-slate-300 bg-slate-50/50 hover:bg-slate-50',
)}
>
<Upload className="mx-auto h-5 w-5 text-slate-400" />
<div className="mt-2 text-sm font-medium text-slate-600">
Kéo thả file vào đây hoặc <span className="text-brand-600">chọn file</span>
</div>
<div className="mt-0.5 text-xs text-slate-400">PDF / DOCX / XLSX / PNG / JPG · tối đa 20 MB</div>
<input
ref={inputRef}
type="file"
multiple
onChange={onPick}
className="hidden"
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
/>
{upload.isPending && (
<div className="mt-2 text-xs text-brand-600">Đang upload</div>
)}
</div>
)}
{attachments.length === 0 && !canEdit && (
<EmptyState icon={Paperclip} title="Chưa có tệp đính kèm nào" />
)}
{attachments.length > 0 && (
<ul className="divide-y divide-slate-100">
{attachments.map(a => {
const Icon = iconFor(a.contentType)
return (
<li key={a.id} className="flex items-center gap-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-500">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-700">{a.fileName}</div>
<div className="text-xs text-slate-400">
{PurposeLabel[a.purpose] ?? '—'} · {fmtSize(a.fileSize)} ·{' '}
{new Date(a.createdAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })}
</div>
</div>
<div className="flex shrink-0 gap-1">
<button
onClick={() => download(a)}
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
title="Tải xuống"
>
<Download className="h-4 w-4" />
</button>
{canEdit && (
<button
onClick={() => {
if (window.confirm(`Xóa tệp "${a.fileName}"?`)) del.mutate(a.id)
}}
disabled={del.isPending}
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-500 transition hover:bg-red-50 hover:text-red-600"
title="Xóa"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</li>
)
})}
</ul>
)}
{canEdit && attachments.length === 0 && (
<div className="py-2 text-center text-xs text-slate-400">
dụ: bản scan đã phase "Đang in ký", bản đóng dấu phase "Đang đóng dấu".
</div>
)}
</section>
)
}