diff --git a/frontend/opsce/features/assets/AssetDocumentUploader.tsx b/frontend/opsce/features/assets/AssetDocumentUploader.tsx new file mode 100644 index 00000000..281247d8 --- /dev/null +++ b/frontend/opsce/features/assets/AssetDocumentUploader.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { Upload, X, FileText, Download, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui/toast'; +import { api } from '@/lib/api'; +import type { AssetDocument } from '@/lib/query/types/asset'; + +const ACCEPTED_TYPES: Record = { + 'application/pdf': ['.pdf'], + 'image/png': ['.png'], + 'image/jpeg': ['.jpg', '.jpeg'], +}; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + +interface AssetDocumentUploaderProps { + assetId: string; + onUploadComplete?: () => void; +} + +export function AssetDocumentUploader({ assetId, onUploadComplete }: AssetDocumentUploaderProps) { + const [documents, setDocuments] = useState([]); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [loading, setLoading] = useState(true); + + // Fetch existing documents on mount + useEffect(() => { + let cancelled = false; + + api.get(`/assets/${assetId}/documents`) + .then((res) => { + if (!cancelled) setDocuments(res.data); + }) + .catch(() => { + if (!cancelled) toast.error('Failed to load documents'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [assetId]); + + const onDrop = useCallback( + async (acceptedFiles: File[]) => { + if (acceptedFiles.length === 0) return; + + const file = acceptedFiles[0]; + + if (file.size > MAX_FILE_SIZE) { + toast.error('File size exceeds 10 MB limit'); + return; + } + + setUploading(true); + setUploadProgress(0); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('name', file.name); + + const response = await api.post( + `/assets/${assetId}/documents`, + formData, + { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const pct = Math.round((progressEvent.loaded * 100) / progressEvent.total); + setUploadProgress(pct); + } + }, + }, + ); + + setDocuments((prev) => [response.data, ...prev]); + toast.success('Document uploaded successfully'); + onUploadComplete?.(); + } catch (err) { + console.error('Upload failed:', err); + toast.error('Failed to upload document. Please try again.'); + } finally { + setUploading(false); + setUploadProgress(0); + } + }, + [assetId, onUploadComplete], + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: ACCEPTED_TYPES, + maxFiles: 1, + maxSize: MAX_FILE_SIZE, + disabled: uploading, + }); + + const handleDelete = async (documentId: string) => { + try { + await api.delete(`/assets/${assetId}/documents/${documentId}`); + setDocuments((prev) => prev.filter((d) => d.id !== documentId)); + toast.success('Document deleted'); + } catch (err) { + console.error('Delete failed:', err); + toast.error('Failed to delete document'); + } + }; + + const handleDownload = async (doc: AssetDocument) => { + try { + const response = await api.get(`/assets/${assetId}/documents/${doc.id}/download`, { + responseType: 'blob', + }); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.download = doc.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error('Download failed:', err); + toast.error('Failed to download document'); + } + }; + + return ( +
+ {/* Dropzone */} +
+ +
+ {uploading ? ( + <> +
+

Uploading... {uploadProgress}%

+ {/* Progress bar */} +
+
+
+ + ) : ( + <> + + {isDragActive ? ( +

Drop your file here

+ ) : ( + <> +

+ Drag & drop a file here, or click to browse +

+

+ PDF, PNG, or JPG up to 10 MB +

+ + )} + + )} +
+
+ + {/* Document list */} + {loading ? ( +
Loading documents...
+ ) : documents.length === 0 ? ( +

No documents uploaded yet.

+ ) : ( +
+ {documents.map((doc) => ( +
+
+ +
+

{doc.name}

+

+ {doc.type} ยท {(doc.size / 1024).toFixed(1)} KB +

+
+
+
+ + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2a376a59..b54b81d7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -51,7 +51,7 @@ "postcss": "^8.5.6", "tailwindcss": "^4.1.18", "ts-jest": "^29.4.4", - "typescript": "^5" + "typescript": "^5.9.3" } }, "node_modules/@alloc/quick-lru": {