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() { + +