/**
* 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) */}
{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 && (
)}
);
};
export default FileUploadModal;