[CLAUDE] App+Infra+Api+FE: Attachment upload E2E
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m40s
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:
224
fe-admin/src/components/ContractAttachmentsSection.tsx
Normal file
224
fe-admin/src/components/ContractAttachmentsSection.tsx
Normal 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">
|
||||||
|
Ví dụ: bản scan HĐ đã ký ở phase "Đang in ký", bản đóng dấu ở phase "Đang đóng dấu".
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { toast } from 'sonner'
|
|||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
|
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
@ -188,6 +189,8 @@ export function ContractDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside>
|
<aside>
|
||||||
|
|||||||
224
fe-user/src/components/ContractAttachmentsSection.tsx
Normal file
224
fe-user/src/components/ContractAttachmentsSection.tsx
Normal 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">
|
||||||
|
Ví dụ: bản scan HĐ đã ký ở phase "Đang in ký", bản đóng dấu ở phase "Đang đóng dấu".
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import { toast } from 'sonner'
|
|||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||||
import { SlaTimer } from '@/components/SlaTimer'
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
|
import { ContractAttachmentsSection } from '@/components/ContractAttachmentsSection'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
@ -179,6 +180,8 @@ export function ContractDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ContractAttachmentsSection contractId={c.id} attachments={c.attachments} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside>
|
<aside>
|
||||||
|
|||||||
@ -66,6 +66,41 @@ public class ContractsController(IMediator mediator) : ControllerBase
|
|||||||
await mediator.Send(new DeleteContractCommand(id), ct);
|
await mediator.Send(new DeleteContractCommand(id), ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Attachments ====================
|
||||||
|
|
||||||
|
// Multipart upload: file + purpose + optional note
|
||||||
|
[HttpPost("{id:guid}/attachments")]
|
||||||
|
[RequestSizeLimit(25_000_000)] // ~24 MB ceiling (validator enforces 20 MB)
|
||||||
|
public async Task<ActionResult<ContractAttachmentDto>> UploadAttachment(
|
||||||
|
Guid id,
|
||||||
|
IFormFile file,
|
||||||
|
[FromForm] AttachmentPurpose purpose = AttachmentPurpose.Other,
|
||||||
|
[FromForm] string? note = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
return BadRequest(new { detail = "Chưa chọn file." });
|
||||||
|
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
var dto = await mediator.Send(new UploadContractAttachmentCommand(
|
||||||
|
id, file.FileName, file.ContentType, file.Length, stream, purpose, note), ct);
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/attachments/{attId:guid}/download")]
|
||||||
|
public async Task<IActionResult> DownloadAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var file = await mediator.Send(new DownloadContractAttachmentQuery(id, attId), ct);
|
||||||
|
return File(file.Content, file.ContentType, file.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/attachments/{attId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteContractAttachmentCommand(id, attId), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment);
|
public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment);
|
||||||
|
|||||||
@ -50,5 +50,8 @@
|
|||||||
"RateLimit": {
|
"RateLimit": {
|
||||||
"AuthLoginPerMinute": 5,
|
"AuthLoginPerMinute": 5,
|
||||||
"GlobalPerMinute": 300
|
"GlobalPerMinute": 300
|
||||||
|
},
|
||||||
|
"Uploads": {
|
||||||
|
"RootPath": "C:\\inetpub\\solution-erp\\uploads"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,5 +26,8 @@
|
|||||||
"RateLimit": {
|
"RateLimit": {
|
||||||
"AuthLoginPerMinute": 5,
|
"AuthLoginPerMinute": 5,
|
||||||
"GlobalPerMinute": 300
|
"GlobalPerMinute": 300
|
||||||
|
},
|
||||||
|
"Uploads": {
|
||||||
|
"RootPath": "uploads"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
namespace SolutionErp.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
// Abstraction over file storage. LocalFileStorage (Infrastructure) writes to
|
||||||
|
// a configured root path today; future: S3/AzureBlob by swapping impl.
|
||||||
|
public interface IFileStorage
|
||||||
|
{
|
||||||
|
// Saves stream to relative path under root. Returns normalized relative path.
|
||||||
|
Task<string> SaveAsync(string relativePath, Stream content, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Opens a readable stream for downloading. Caller must dispose.
|
||||||
|
Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Deletes a file if it exists. No-op if missing.
|
||||||
|
Task DeleteAsync(string relativePath, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// True if file exists at relative path.
|
||||||
|
bool Exists(string relativePath);
|
||||||
|
}
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Contracts.Dtos;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.Contracts;
|
||||||
|
|
||||||
|
// ========== UPLOAD ==========
|
||||||
|
// File payload is decoupled from the command so MediatR-style CQRS works with
|
||||||
|
// multipart/form-data: the controller reads IFormFile, hands the Application
|
||||||
|
// layer a bare Stream + metadata. Keeps Application free of ASP.NET types.
|
||||||
|
|
||||||
|
public record UploadContractAttachmentCommand(
|
||||||
|
Guid ContractId,
|
||||||
|
string FileName,
|
||||||
|
string ContentType,
|
||||||
|
long FileSize,
|
||||||
|
Stream Content,
|
||||||
|
AttachmentPurpose Purpose,
|
||||||
|
string? Note) : IRequest<ContractAttachmentDto>;
|
||||||
|
|
||||||
|
public class UploadContractAttachmentCommandValidator : AbstractValidator<UploadContractAttachmentCommand>
|
||||||
|
{
|
||||||
|
// 20 MB default — configurable later via Uploads:MaxFileSize if needed
|
||||||
|
private const long MaxBytes = 20L * 1024 * 1024;
|
||||||
|
|
||||||
|
// MIME whitelist — contract scans (pdf/image) + editable sources (word/excel)
|
||||||
|
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"application/pdf",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
||||||
|
"application/msword", // .doc
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
|
||||||
|
"application/vnd.ms-excel", // .xls
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
public UploadContractAttachmentCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ContractId).NotEmpty();
|
||||||
|
RuleFor(x => x.FileName).NotEmpty().MaximumLength(255);
|
||||||
|
RuleFor(x => x.FileSize).GreaterThan(0).LessThanOrEqualTo(MaxBytes)
|
||||||
|
.WithMessage("File vượt quá 20 MB.");
|
||||||
|
RuleFor(x => x.ContentType).Must(c => AllowedContentTypes.Contains(c))
|
||||||
|
.WithMessage("Định dạng file không được hỗ trợ. Cho phép: pdf, doc(x), xls(x), png, jpg, webp.");
|
||||||
|
RuleFor(x => x.Purpose).IsInEnum();
|
||||||
|
RuleFor(x => x.Note).MaximumLength(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UploadContractAttachmentCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
IFileStorage storage) : IRequestHandler<UploadContractAttachmentCommand, ContractAttachmentDto>
|
||||||
|
{
|
||||||
|
public async Task<ContractAttachmentDto> Handle(UploadContractAttachmentCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var contract = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.ContractId, ct)
|
||||||
|
?? throw new NotFoundException("Contract", request.ContractId);
|
||||||
|
|
||||||
|
var attId = Guid.NewGuid();
|
||||||
|
var safeName = SanitizeFileName(request.FileName);
|
||||||
|
// Store under contracts/{contractId}/{attId}_{safeName} to keep originals separate
|
||||||
|
var relativePath = $"contracts/{contract.Id}/{attId}_{safeName}";
|
||||||
|
|
||||||
|
await storage.SaveAsync(relativePath, request.Content, ct);
|
||||||
|
|
||||||
|
var entity = new ContractAttachment
|
||||||
|
{
|
||||||
|
Id = attId,
|
||||||
|
ContractId = contract.Id,
|
||||||
|
FileName = request.FileName, // keep original for display
|
||||||
|
StoragePath = relativePath,
|
||||||
|
FileSize = request.FileSize,
|
||||||
|
ContentType = request.ContentType,
|
||||||
|
Purpose = request.Purpose,
|
||||||
|
Note = request.Note,
|
||||||
|
};
|
||||||
|
db.ContractAttachments.Add(entity);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new ContractAttachmentDto(
|
||||||
|
entity.Id, entity.FileName, entity.StoragePath, entity.FileSize,
|
||||||
|
entity.ContentType, entity.Purpose, entity.Note, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string name)
|
||||||
|
{
|
||||||
|
// Strip path components + replace chars that are invalid on filesystems
|
||||||
|
var baseName = Path.GetFileName(name);
|
||||||
|
foreach (var c in Path.GetInvalidFileNameChars())
|
||||||
|
baseName = baseName.Replace(c, '_');
|
||||||
|
// Also strip leading dots (hidden files) and collapse whitespace
|
||||||
|
baseName = baseName.TrimStart('.');
|
||||||
|
if (string.IsNullOrWhiteSpace(baseName)) baseName = "file";
|
||||||
|
return baseName.Length > 200 ? baseName[^200..] : baseName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DOWNLOAD ==========
|
||||||
|
|
||||||
|
public record DownloadContractAttachmentQuery(Guid ContractId, Guid AttachmentId) : IRequest<ContractAttachmentFile>;
|
||||||
|
|
||||||
|
public record ContractAttachmentFile(Stream Content, string FileName, string ContentType);
|
||||||
|
|
||||||
|
public class DownloadContractAttachmentQueryHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
IFileStorage storage) : IRequestHandler<DownloadContractAttachmentQuery, ContractAttachmentFile>
|
||||||
|
{
|
||||||
|
public async Task<ContractAttachmentFile> Handle(DownloadContractAttachmentQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var att = await db.ContractAttachments.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.ContractId == request.ContractId, ct)
|
||||||
|
?? throw new NotFoundException("Attachment", request.AttachmentId);
|
||||||
|
|
||||||
|
var stream = await storage.OpenReadAsync(att.StoragePath, ct);
|
||||||
|
return new ContractAttachmentFile(stream, att.FileName, att.ContentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DELETE ==========
|
||||||
|
|
||||||
|
public record DeleteContractAttachmentCommand(Guid ContractId, Guid AttachmentId) : IRequest;
|
||||||
|
|
||||||
|
public class DeleteContractAttachmentCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
IFileStorage storage) : IRequestHandler<DeleteContractAttachmentCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(DeleteContractAttachmentCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var att = await db.ContractAttachments
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.ContractId == request.ContractId, ct)
|
||||||
|
?? throw new NotFoundException("Attachment", request.AttachmentId);
|
||||||
|
|
||||||
|
db.ContractAttachments.Remove(att);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Best-effort file delete — if it fails, DB row is already gone (orphan
|
||||||
|
// cleanup job can sweep storage later). Don't block API response.
|
||||||
|
try { await storage.DeleteAsync(att.StoragePath, ct); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ using SolutionErp.Infrastructure.Persistence;
|
|||||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||||
using SolutionErp.Infrastructure.Reports;
|
using SolutionErp.Infrastructure.Reports;
|
||||||
using SolutionErp.Infrastructure.Services;
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Storage;
|
||||||
|
|
||||||
namespace SolutionErp.Infrastructure;
|
namespace SolutionErp.Infrastructure;
|
||||||
|
|
||||||
@ -32,6 +33,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||||
services.AddScoped<INotificationService, NotificationService>();
|
services.AddScoped<INotificationService, NotificationService>();
|
||||||
|
services.AddSingleton<IFileStorage, LocalFileStorage>();
|
||||||
|
|
||||||
// Phase 3 iteration 2 — SLA auto-approve background service
|
// Phase 3 iteration 2 — SLA auto-approve background service
|
||||||
services.AddHostedService<SlaExpiryJob>();
|
services.AddHostedService<SlaExpiryJob>();
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Storage;
|
||||||
|
|
||||||
|
public class LocalFileStorage : IFileStorage
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
|
||||||
|
public LocalFileStorage(IConfiguration config)
|
||||||
|
{
|
||||||
|
var root = config["Uploads:RootPath"] ?? "uploads";
|
||||||
|
// Make absolute (relative paths resolve against ContentRoot)
|
||||||
|
_root = Path.GetFullPath(root);
|
||||||
|
Directory.CreateDirectory(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Full(string rel)
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(Path.Combine(_root, rel));
|
||||||
|
// Guard against path traversal — resolved full path must stay inside root
|
||||||
|
if (!full.StartsWith(_root, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new UnauthorizedAccessException("Path traversal blocked.");
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> SaveAsync(string relativePath, Stream content, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var full = Full(relativePath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||||
|
await using var fs = File.Create(full);
|
||||||
|
await content.CopyToAsync(fs, ct);
|
||||||
|
return relativePath.Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var full = Full(relativePath);
|
||||||
|
if (!File.Exists(full)) throw new FileNotFoundException(relativePath);
|
||||||
|
return Task.FromResult<Stream>(File.OpenRead(full));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var full = Full(relativePath);
|
||||||
|
if (File.Exists(full)) File.Delete(full);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(string relativePath) => File.Exists(Full(relativePath));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user