/** * FileUploadModal * Upload New File modal — drag & drop or click to select, up to 10 files * Fields: entity_type, entity_id, category_id (select), tags, description * Matches backend UploadFileSchema exactly */ import { useCallback, useRef, useState, type ChangeEvent, type DragEvent, type ReactElement, useEffect, } from "react"; import { X, Upload, FileText, Image, FileArchive, AlertCircle, Table, CheckCircle2, Loader2, RefreshCw, ChevronDown as ChevronDownIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Modal, PrimaryButton, SecondaryButton, FormField, FormSelect, FormTextArea, FormTagInput, } from "@/components/shared"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Input } from "@/components/ui/input"; import fileAttachmentService, { type CategoriesFilterOptions, type StorageQuota, } from "@/services/file-attachment-service"; // ───────────────────────────────────────────────────────────────────────────── // Constants from backend blocked extensions // ───────────────────────────────────────────────────────────────────────────── const BLOCKED_EXTENSIONS = [ ".exe",".bat",".cmd",".sh",".ps1",".msi",".dll",".com",".scr",".vbs",".js", ]; const MAX_FILES = 10; const MAX_SIZE_MB = 50; // UI shows 50 MB as the soft warning (backend: 100 MB) // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function getExt(name: string) { return name.slice(((name.lastIndexOf(".") - 1) >>> 0) + 1).toLowerCase(); } // function isBlocked(name: string) { // return BLOCKED_EXTENSIONS.includes(getExt(name) ? `.${getExt(name)}` : ""); // } function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } function generateUUID(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } // Fallback for non-secure contexts or older browsers return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0; const v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function getFileIcon(mime: string, name: string): ReactElement { if (mime.startsWith("image/")) return ; if (mime === "application/pdf") return ; if (mime.includes("spreadsheet") || name.endsWith(".csv") || name.endsWith(".xlsx")) return ; if (mime.includes("zip") || mime.includes("archive")) return ; return ; } // ───────────────────────────────────────────────────────────────────────────── // Per-file state // ───────────────────────────────────────────────────────────────────────────── type FileStatus = "idle" | "uploading" | "done" | "error" | "blocked"; interface FileEntry { file: File; id: string; status: FileStatus; progress: number; error?: string; } // ───────────────────────────────────────────────────────────────────────────── // Props // ───────────────────────────────────────────────────────────────────────────── export interface FileUploadModalProps { isOpen: boolean; onClose: () => void; onUploaded?: () => void; /** Pre-fill entity type (e.g. "document") */ defaultEntityType?: string; /** Pre-fill entity id */ defaultEntityId?: string; categories?: CategoriesFilterOptions["categories"]; /** For tenant-admin: show all fields; for tenant-user: same but with permission gating done by parent */ isTenantAdmin?: boolean; } // ───────────────────────────────────────────────────────────────────────────── // ENTITY TYPE OPTIONS (common entity types in the platform) // ───────────────────────────────────────────────────────────────────────────── const ENTITY_TYPES = [ "document", "capa", "training", "supplier", "audit", "project", "workflow", "tenant", "user", "other", ]; // ───────────────────────────────────────────────────────────────────────────── // Validation Schema // ───────────────────────────────────────────────────────────────────────────── const fileUploadSchema = z.object({ entity_type: z.string().min(1, "Entity Type is required"), entity_id: z.string().min(1, "Entity ID is required"), category: z.string().optional(), tags: z.array(z.string()).default([]), description: z.string().max(500, "Description must be at most 500 characters").optional(), files: z.array(z.any()).min(1, "At least one file is required"), }); type FileUploadFormData = z.infer; // ───────────────────────────────────────────────────────────────────────────── // Component // ───────────────────────────────────────────────────────────────────────────── export const FileUploadModal = ({ isOpen, onClose, onUploaded, defaultEntityType = "", defaultEntityId = "", categories = [], isTenantAdmin = true, }: FileUploadModalProps): ReactElement | null => { // ── Form configuration ── const { control, handleSubmit, setValue, watch, reset, formState: { errors }, } = useForm({ resolver: zodResolver(fileUploadSchema) as any, defaultValues: { entity_type: defaultEntityType, entity_id: defaultEntityId, category: "", tags: [], description: "", files: [], }, }); // Watch form values const entityId = watch("entity_id"); const categoryInput = watch("category"); // ── Local UI state ── const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [uploadSuccess, setUploadSuccess] = useState(false); const [showCategoryDropdown, setShowCategoryDropdown] = useState(false); const [categorySearch, setCategorySearch] = useState(""); // Ref for the hidden file input const inputRef = useRef(null); const categoryDropdownRef = useRef(null); // File entries with upload progress (internal state kept separate from zod files array for progress tracking) const [fileEntries, setFileEntries] = useState([]); // Storage quota settings fetched dynamically from backend const [quota, setQuota] = useState(null); useEffect(() => { if (isOpen) { fileAttachmentService.getQuota() .then((res) => { if (res?.data) { setQuota(res.data); } }) .catch((err) => { console.error("Failed to load quota settings in FileUploadModal:", err); }); } }, [isOpen]); // ── Auto-generate Entity ID ── useEffect(() => { if (isOpen && !entityId && !defaultEntityId) { setValue("entity_id", generateUUID()); } }, [isOpen, defaultEntityId, entityId, setValue]); // ── Close dropdown on click outside ── useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (categoryDropdownRef.current && !categoryDropdownRef.current.contains(event.target as Node)) { setShowCategoryDropdown(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // ── Reset on close ── const handleClose = () => { if (isUploading) return; reset({ entity_type: defaultEntityType, entity_id: defaultEntityId || (isOpen ? generateUUID() : ""), category: "", tags: [], description: "", files: [], }); setFileEntries([]); setUploadError(null); setUploadSuccess(false); onClose(); }; // ── Add files ── const addFiles = useCallback((incoming: File[]) => { const activeBlocked = quota?.blocked_extensions || BLOCKED_EXTENSIONS; const activeMaxSizeBytes = quota?.max_file_size_bytes || (MAX_SIZE_MB * 1024 * 1024); const newEntries: FileEntry[] = incoming .slice(0, MAX_FILES) .map((file) => { const ext = getExt(file.name); const fileExt = ext ? `.${ext}` : ""; const isBlockedType = activeBlocked.some( (blockedExt) => blockedExt.toLowerCase() === fileExt.toLowerCase() ); const isTooLarge = file.size > activeMaxSizeBytes; let status: FileStatus = "idle"; let error: string | undefined; if (isBlockedType) { status = "blocked"; error = "Blocked file type"; } else if (isTooLarge) { status = "blocked"; error = `Exceeds limit of ${formatBytes(activeMaxSizeBytes)}`; } return { file, id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`, status, progress: 0, error, }; }); setFileEntries((prev) => { const combined = [...prev, ...newEntries]; const limited = combined.slice(0, MAX_FILES); // Sync with react-hook-form setValue("files", limited.map(e => e.file), { shouldValidate: true }); return limited; }); }, [setValue, quota]); const onDrop = useCallback( (e: DragEvent) => { e.preventDefault(); setIsDragging(false); const dropped = Array.from(e.dataTransfer.files); addFiles(dropped); }, [addFiles] ); const onInputChange = (e: ChangeEvent) => { if (e.target.files) addFiles(Array.from(e.target.files)); e.target.value = ""; }; const removeFile = (id: string) => { setFileEntries((prev) => { const filtered = prev.filter((f) => f.id !== id); // Sync with react-hook-form setValue("files", filtered.map(e => e.file), { shouldValidate: true }); return filtered; }); }; // ── Upload ── const handleUpload = async (data: FileUploadFormData) => { const validFiles = fileEntries.filter((f) => f.status !== "blocked"); if (validFiles.length === 0) { setUploadError("No valid files to upload"); return; } setIsUploading(true); setUploadError(null); // Mark all valid as uploading setFileEntries((prev) => prev.map((f) => (f.status === "idle" ? { ...f, status: "uploading", progress: 10 } : f)) ); try { // Find matching category ID if it exists in the list const matchedCategory = categories.find( (c) => c.category.toLowerCase() === (data.category || "").toLowerCase() ); if (validFiles.length === 1) { // Single upload await fileAttachmentService.upload({ files: [validFiles[0].file], entity_type: data.entity_type, entity_id: data.entity_id, category: matchedCategory ? matchedCategory.category : data.category, category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined, description: data.description?.trim() || undefined, tags: data.tags?.length ? data.tags : undefined, }); setFileEntries((prev) => prev.map((f) => f.id === validFiles[0].id ? { ...f, status: "done", progress: 100 } : f ) ); } else { // Multiple upload const result = await fileAttachmentService.uploadMultiple( { files: validFiles.map((f) => f.file), entity_type: data.entity_type, entity_id: data.entity_id, category: matchedCategory ? matchedCategory.category : data.category, category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined, description: data.description?.trim() || undefined, tags: data.tags?.length ? data.tags : undefined, }, (_, percent) => { setFileEntries((prev) => prev.map((f) => f.status === "uploading" ? { ...f, progress: Math.max(f.progress, percent) } : f ) ); } ); setFileEntries((prev) => prev.map((f) => { const errEntry = result.data.errors.find((e) => e.file === f.file.name); if (errEntry) return { ...f, status: "error", error: errEntry.error, progress: 0 }; if (f.status === "uploading") return { ...f, status: "done", progress: 100 }; return f; }) ); } setUploadSuccess(true); onUploaded?.(); } catch (err: any) { const msg = err?.response?.data?.error?.message || err?.message || "Upload failed"; setUploadError(msg); setFileEntries((prev) => prev.map((f) => f.status === "uploading" ? { ...f, status: "error", error: msg, progress: 0 } : f ) ); } finally { setIsUploading(false); } }; if (!isOpen) return null; const validCount = fileEntries.filter((f) => f.status !== "blocked").length; const doneCount = fileEntries.filter((f) => f.status === "done").length; const footer = ( <> Cancel {isUploading ? ( <> Uploading ({doneCount}/{validCount}) ) : ( <> Upload {validCount > 0 ? `(${validCount})` : ""} )} ); return (
{/* Drop Zone */}

Attach Files *

{fileEntries.length === 0 ? (
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={onDrop} onClick={() => inputRef.current?.click()} className={cn( "border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all", errors.files ? "border-[#ef4444] bg-[#ef4444]/5" : isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50" )} >

Click to upload or drag and drop

Attach supporting source files via File Attachment Service

Allowed formats up to {quota ? formatBytes(quota.max_file_size_bytes) : `${MAX_SIZE_MB}MB`}

) : (
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={onDrop} className={cn( "border-2 border-dashed rounded-xl transition-all", errors.files ? "border-[#ef4444] bg-[#ef4444]/5" : isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.08)]" )} > {/* Add files button */}
inputRef.current?.click()} className="flex items-center justify-center gap-2 py-3 cursor-pointer border-b border-[rgba(0,0,0,0.06)] hover:bg-gray-50 transition-all rounded-t-xl" >
Add Files
{/* File list */}
{fileEntries.map((entry) => (
{getFileIcon(entry.file.type, entry.file.name)}
{entry.file.name} {formatBytes(entry.file.size)} {entry.status === "uploading" && ( {entry.progress}% )} {entry.status === "done" && ( Complete )} {(entry.status === "blocked" || entry.status === "error") && ( {entry.error} )}
{entry.status === "uploading" && (
)} {entry.status === "done" && (
)} {(entry.status === "blocked" || entry.status === "error") && (
)}
{entry.status !== "uploading" && entry.status !== "done" && ( )}
))}
)} {errors.files && (

{errors.files.message}

)}

Up to {MAX_FILES} files allowed

{uploadSuccess && (
Files upload successfully.
)}
{/* Fields: Entity Type + Entity ID */}
( ({ value: t, label: t.charAt(0).toUpperCase() + t.slice(1), }))} placeholder="Select type" error={errors.entity_type?.message} /> )} />
( )} /> {!defaultEntityId && !isUploading && isTenantAdmin && ( )}
{/* Category Name (Editable Combobox) */}
( { field.onChange(e.target.value); setCategorySearch(e.target.value); setShowCategoryDropdown(true); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (categorySearch.trim()) { field.onChange(categorySearch.trim()); setShowCategoryDropdown(false); } } }} onFocus={() => setShowCategoryDropdown(true)} disabled={isUploading} placeholder="Type or select a category..." className="pr-10" /> )} />
{categoryInput && ( )}
{errors.category && (

{errors.category.message}

)} {showCategoryDropdown && !isUploading && (
{categories.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())).length > 0 ? ( categories .filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())) .map((cat) => ( )) ) : (

{categorySearch ? `Use "${categorySearch}"` : "No categories found"}

{categorySearch ? "Press Enter to create this as a new category" : "Start typing to create a new one"}

)}
)}
{/* Tags */}
( )} />
{/* Description */} ( )} /> {/* Upload error */} {uploadError && (
{uploadError}
)}
); }; export default FileUploadModal;