import { useState, useCallback, useEffect } from "react"; import { useDropzone } from "react-dropzone"; import { useNotify, useGetIdentity, useGetList, useTranslate } from "ra-core"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ds/ui/card"; import { Button } from "@/components/ds/ui/button"; import { Progress } from "@/components/ds/ui/progress"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ds/ui/select"; import { Label } from "@/components/ds/ui/label"; import { Upload, FileIcon, CheckCircle, XCircle, Loader2 } from "lucide-react"; import { getSupabaseConfig } from "@/lib/supabase-config"; interface UploadFile { file: File; status: "pending" | "uploading" | "success" | "error"; progress: number; activityId?: string; error?: string; } interface IngestionProvider { id: string; name: string; provider_code: string; ingestion_key: string; } export const FileUpload = () => { const [files, setFiles] = useState([]); const [activityType, setActivityType] = useState("note"); const [selectedProvider, setSelectedProvider] = useState(""); const notify = useNotify(); const { data: identity } = useGetIdentity(); const translate = useTranslate(); // Load ingestion providers using React-Admin hook (cleaner than manual useEffect) const { data: providers = [] } = useGetList( "ingestion_providers", { filter: { is_active: true }, pagination: { page: 1, perPage: 100 }, sort: { field: "created_at", order: "DESC" }, }, ); // Auto-select first provider when data loads useEffect(() => { if (providers.length > 0 && !selectedProvider) { setSelectedProvider(providers[0].id); } }, [providers, selectedProvider]); const onDrop = useCallback((acceptedFiles: File[]) => { const newFiles = acceptedFiles.map((file) => ({ file, status: "pending" as const, progress: 0, })); setFiles((prev) => [...prev, ...newFiles]); }, []); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, // Accept all file types (removed restrictive accept prop) // Only block dangerous executable files for security validator: (file) => { // Guard against missing file name if (!file.name) { return null; // Allow files without names (edge case) } const dangerous = [ ".exe", ".bat", ".cmd", ".com", ".scr", ".vbs", ".ps1", ".msi", ]; const fileName = file.name.toLowerCase(); const dotIndex = fileName.lastIndexOf("."); // If no extension, allow the file if (dotIndex === -1) { return null; } const ext = fileName.slice(dotIndex); if (dangerous.includes(ext)) { return { code: "dangerous-file", message: translate( "crm.integrations.file_upload.notification.error_dangerous", ), }; } return null; }, }); const uploadFile = async (index: number) => { const fileToUpload = files[index]; const provider = providers.find((p) => p.id === selectedProvider); if (!provider) { notify( translate("crm.integrations.file_upload.notification.select_channel"), { type: "error", }, ); return; } const config = getSupabaseConfig(); const webhookUrl = `${config.url}/functions/v1/ingest-activity`; // Update status to uploading setFiles((prev) => prev.map((f, i) => i === index ? { ...f, status: "uploading", progress: 0 } : f, ), ); try { const formData = new FormData(); formData.append("file", fileToUpload.file); formData.append("type", activityType); formData.append("from", identity?.email || "manual-upload"); formData.append("subject", `File Upload: ${fileToUpload.file.name}`); const xhr = new XMLHttpRequest(); // Track upload progress xhr.upload.addEventListener("progress", (e) => { if (e.lengthComputable) { const progress = Math.round((e.loaded / e.total) * 100); setFiles((prev) => prev.map((f, i) => (i === index ? { ...f, progress } : f)), ); } }); xhr.addEventListener("load", () => { if (xhr.status >= 200 && xhr.status < 300) { const response = JSON.parse(xhr.responseText); setFiles((prev) => prev.map((f, i) => i === index ? { ...f, status: "success", progress: 100, activityId: response.id, } : f, ), ); notify( translate("crm.integrations.file_upload.notification.success", { name: fileToUpload.file.name, }), { type: "success" }, ); } else { throw new Error(`Upload failed with status ${xhr.status}`); } }); xhr.addEventListener("error", () => { setFiles((prev) => prev.map((f, i) => i === index ? { ...f, status: "error", error: translate( "crm.integrations.file_upload.notification.error_network", ), } : f, ), ); notify( translate("crm.integrations.file_upload.notification.error", { name: fileToUpload.file.name, }), { type: "error" }, ); }); xhr.open("POST", webhookUrl); // Security: Move ingestion key from URL to header (prevents key leakage in logs) xhr.setRequestHeader("x-ingestion-key", provider.ingestion_key); xhr.send(formData); } catch (error) { setFiles((prev) => prev.map((f, i) => i === index ? { ...f, status: "error", error: String(error) } : f, ), ); notify( translate("crm.integrations.file_upload.notification.error", { name: fileToUpload.file.name, }), { type: "error" }, ); } }; const uploadAll = async () => { const pendingFiles = files .map((f, index) => ({ file: f, index })) .filter(({ file }) => file.status === "pending"); // Upload all files in parallel for better performance // Browsers manage connection limits automatically (usually 6 concurrent) await Promise.all(pendingFiles.map(({ index }) => uploadFile(index))); }; const clearCompleted = () => { setFiles((prev) => prev.filter((f) => f.status !== "success")); }; const formatFileSize = (bytes: number) => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; }; return (
{translate("crm.integrations.file_upload.title")} {translate("crm.integrations.file_upload.description")} {/* Configuration */}
{/* Dropzone */}
{isDragActive ? (

{translate("crm.integrations.file_upload.action.drop_files")}

) : ( <>

{translate( "crm.integrations.file_upload.action.drag_and_drop", )}

{translate( "crm.integrations.file_upload.action.supports_all", )}

)}
{/* File List */} {files.length > 0 && (

{translate( "crm.integrations.file_upload.fields.files_count", { count: files.length, }, )}

{files.map((fileItem, index) => (

{fileItem.file.name}

{formatFileSize(fileItem.file.size)} {fileItem.activityId && ( ID: {fileItem.activityId.substring(0, 8)}... )}

{fileItem.status === "pending" && ( )} {fileItem.status === "uploading" && ( )} {fileItem.status === "success" && ( )} {fileItem.status === "error" && ( )}
{/* Progress bar */} {fileItem.status === "uploading" && ( )} {/* Error message */} {fileItem.status === "error" && fileItem.error && (

Error: {fileItem.error}

)}
))}
)}
{/* Info Card */} {translate("crm.integrations.file_upload.how_it_works.title")}

• {translate("crm.integrations.file_upload.how_it_works.step_1")}

• {translate("crm.integrations.file_upload.how_it_works.step_2")}

• {translate("crm.integrations.file_upload.how_it_works.step_3")}

• {translate("crm.integrations.file_upload.how_it_works.step_4")}

• {translate("crm.integrations.file_upload.how_it_works.step_5")}

); };