224 lines
8.1 KiB
TypeScript
224 lines
8.1 KiB
TypeScript
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<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>
|
|
)
|
|
}
|