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 { 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".
)}
) }