From c8d0070770dd257af0c0e9bb6643de99603124ab Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 21 Apr 2026 20:37:35 +0700 Subject: [PATCH] [CLAUDE] App+Infra+Api+FE: Attachment upload E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/ContractAttachmentsSection.tsx | 224 ++++++++++++++++++ .../pages/contracts/ContractDetailPage.tsx | 3 + .../components/ContractAttachmentsSection.tsx | 224 ++++++++++++++++++ .../pages/contracts/ContractDetailPage.tsx | 3 + .../Controllers/ContractsController.cs | 35 +++ .../appsettings.Production.json.example | 3 + src/Backend/SolutionErp.Api/appsettings.json | 3 + .../Common/Interfaces/IFileStorage.cs | 18 ++ .../Contracts/ContractAttachmentFeatures.cs | 147 ++++++++++++ .../DependencyInjection.cs | 2 + .../Storage/LocalFileStorage.cs | 51 ++++ 11 files changed, 713 insertions(+) create mode 100644 fe-admin/src/components/ContractAttachmentsSection.tsx create mode 100644 fe-user/src/components/ContractAttachmentsSection.tsx create mode 100644 src/Backend/SolutionErp.Application/Common/Interfaces/IFileStorage.cs create mode 100644 src/Backend/SolutionErp.Application/Contracts/ContractAttachmentFeatures.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Storage/LocalFileStorage.cs diff --git a/fe-admin/src/components/ContractAttachmentsSection.tsx b/fe-admin/src/components/ContractAttachmentsSection.tsx new file mode 100644 index 0000000..e1310e5 --- /dev/null +++ b/fe-admin/src/components/ContractAttachmentsSection.tsx @@ -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 = { + 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(null) + const [dragging, setDragging] = useState(false) + const [purpose, setPurpose] = useState(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) { + e.preventDefault() + setDragging(false) + if (!canEdit) return + handleFiles(e.dataTransfer.files) + } + + function onPick(e: ChangeEvent) { + 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 ( +
+
+

+ + Tệp đính kèm ({attachments.length}) +

+ {canEdit && ( +
+ +
+ )} +
+ + {canEdit && ( +
{ + 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', + )} + > + +
+ Kéo thả file vào đây hoặc chọn file +
+
PDF / DOCX / XLSX / PNG / JPG · tối đa 20 MB
+ + {upload.isPending && ( +
Đang upload…
+ )} +
+ )} + + {attachments.length === 0 && !canEdit && ( + + )} + + {attachments.length > 0 && ( +
    + {attachments.map(a => { + const Icon = iconFor(a.contentType) + return ( +
  • +
    + +
    +
    +
    {a.fileName}
    +
    + {PurposeLabel[a.purpose] ?? '—'} · {fmtSize(a.fileSize)} ·{' '} + {new Date(a.createdAt).toLocaleString('vi-VN', { dateStyle: 'short', timeStyle: 'short' })} +
    +
    +
    + + {canEdit && ( + + )} +
    +
  • + ) + })} +
+ )} + + {canEdit && attachments.length === 0 && ( +
+ Ví dụ: bản scan HĐ đã ký ở phase "Đang in ký", bản đóng dấu ở phase "Đang đóng dấu". +
+ )} +
+ ) +} diff --git a/fe-admin/src/pages/contracts/ContractDetailPage.tsx b/fe-admin/src/pages/contracts/ContractDetailPage.tsx index 8460896..2e4e9e1 100644 --- a/fe-admin/src/pages/contracts/ContractDetailPage.tsx +++ b/fe-admin/src/pages/contracts/ContractDetailPage.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner' import { PageHeader } from '@/components/PageHeader' import { PhaseBadge } from '@/components/PhaseBadge' import { SlaTimer } from '@/components/SlaTimer' +import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection' import { Button } from '@/components/ui/Button' import { Select } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' @@ -188,6 +189,8 @@ export function ContractDetailPage() { + +