import React, { useState, useMemo, useRef } from "react"; import { IFileUploadSetting, IFileUploadField } from "@edr/types/freight"; import { UploadCloud, FileText, Image as ImageIcon, File, Trash2, AlertCircle, CheckCircle2, } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "../button"; export interface SmartFileInputProps { /** The settings object containing features and their upload fields config. */ file: IFileUploadSetting; /** Controlled value: maps each fileKey to the selected File or File[]. */ value?: Record; /** Callback triggered when any field's files change. */ onChange?: (value: Record) => void; /** External form errors mapped by fileKey. */ errors?: Record; /** * fileKeys whose document is already uploaded on the server. Such fields show * an "Already uploaded" badge and a replace-oriented dropzone hint, even when * no in-memory File is currently selected for them. */ uploadedKeys?: string[]; /** Disabled state for the entire file input group. */ disabled?: boolean; /** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */ variant?: "default" | "minimal"; /** Optional custom container CSS classes. */ className?: string; containerClassName?: string; } /** Helper to format file sizes in bytes to a human-readable string. */ function formatBytes(bytes: number, decimals = 2) { if (bytes === 0) return "0 Bytes"; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; } /** Render a suitable icon based on file extension. */ function FileIcon({ name, className }: { name: string; className?: string }) { const ext = name.split(".").pop()?.toLowerCase() || ""; if (ext === "pdf") { return ; } if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) { return ; } if (["csv", "xls", "xlsx"].includes(ext)) { return ; } if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) { return ; } return ; } export function SmartFileInput({ file, value, onChange, errors, uploadedKeys, disabled = false, variant = "default", className, containerClassName, }: SmartFileInputProps) { // Local state to manage files when the component is used in an uncontrolled manner const [internalFiles, setInternalFiles] = useState>( {}, ); // Local validation errors const [localErrors, setLocalErrors] = useState>({}); // Drag-and-drop state active per field const [dragActive, setDragActive] = useState>({}); // File input refs for programmatic clicks in minimal variant const fileInputRefs = useRef>({}); // Memoize fields sorted by the order property (ascending) const sortedFields = useMemo(() => { return [...file.fields].sort((a, b) => a.order - b.order); }, [file.fields]); // Create a map of fields for quick lookup const fieldsMap = useMemo(() => { return file.fields.reduce( (acc, currentField) => { acc[currentField.fileKey] = currentField; return acc; }, {} as Record, ); }, [file.fields]); // Resolve current files list for a field const getFilesForField = (fieldKey: string): File[] => { const val = value ? value[fieldKey] : internalFiles[fieldKey]; if (!val) return []; return Array.isArray(val) ? val : [val]; }; const handleFilesChange = (fieldKey: string, newFiles: File[]) => { const field = fieldsMap[fieldKey]; if (!field) return; const newValue = field.isMultiple ? newFiles : newFiles[0] || null; if (onChange) { const updatedValues = { ...(value || {}), [fieldKey]: newValue, }; onChange(updatedValues); } else { setInternalFiles((prev) => ({ ...prev, [fieldKey]: newFiles, })); } }; const processFiles = (field: IFileUploadField, incomingFiles: File[]) => { const currentFiles = getFilesForField(field.fileKey); const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1; // Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf') const allowedExts = field.allowedExtensions.map((ext) => ext.toLowerCase().replace(/^\./, ""), ); let validIncoming: File[] = []; let errorMsg = ""; for (const fileObj of incomingFiles) { const ext = fileObj.name.split(".").pop()?.toLowerCase() || ""; const isExtValid = allowedExts.length === 0 || allowedExts.includes(ext); const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024; if (!isExtValid) { errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join( ", ", )}`; break; } if (!isSizeValid) { errorMsg = `File size exceeds the limit of ${field.maxSizeMb}MB`; break; } validIncoming.push(fileObj); } if (errorMsg) { setLocalErrors((prev) => ({ ...prev, [field.fileKey]: errorMsg })); return; } // Clear local error on successful file addition setLocalErrors((prev) => { const updated = { ...prev }; delete updated[field.fileKey]; return updated; }); let newFilesList: File[] = []; if (field.isMultiple) { newFilesList = [...currentFiles, ...validIncoming].slice(0, maxAllowed); if (currentFiles.length + validIncoming.length > maxAllowed) { setLocalErrors((prev) => ({ ...prev, [field.fileKey]: `Only up to ${maxAllowed} files are allowed for this field. Excess files were ignored.`, })); } } else { newFilesList = validIncoming.slice(0, 1); } handleFilesChange(field.fileKey, newFilesList); }; const handleDrag = ( e: React.DragEvent, fieldKey: string, active: boolean, ) => { e.preventDefault(); e.stopPropagation(); if (disabled) return; setDragActive((prev) => ({ ...prev, [fieldKey]: active })); }; const handleDrop = (e: React.DragEvent, field: IFileUploadField) => { e.preventDefault(); e.stopPropagation(); if (disabled) return; setDragActive((prev) => ({ ...prev, [field.fileKey]: false })); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { const filesArray = Array.from(e.dataTransfer.files); processFiles(field, filesArray); } }; const handleFileSelect = ( e: React.ChangeEvent, field: IFileUploadField, ) => { if (e.target.files && e.target.files.length > 0) { const filesArray = Array.from(e.target.files); processFiles(field, filesArray); e.target.value = ""; // reset so same file can be selected again } }; const removeFile = (fieldKey: string, indexToRemove: number) => { if (disabled) return; const currentFiles = getFilesForField(fieldKey); const updatedFiles = currentFiles.filter((_, idx) => idx !== indexToRemove); const field = fieldsMap[fieldKey]; if (field && field.isRequired && updatedFiles.length === 0) { setLocalErrors((prev) => ({ ...prev, [fieldKey]: "This file is required", })); } else { setLocalErrors((prev) => { const updated = { ...prev }; delete updated[fieldKey]; return updated; }); } handleFilesChange(fieldKey, updatedFiles); }; return (
{file.description && (
{file.description}
)}
{sortedFields.map((field) => { const currentFiles = getFilesForField(field.fileKey); const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1; const reachedLimit = currentFiles.length >= maxFiles; const fieldError = errors?.[field.fileKey] || localErrors[field.fileKey]; const isDragOver = dragActive[field.fileKey]; // Already uploaded server-side and nothing newly picked to replace it. const isUploaded = (uploadedKeys?.includes(field.fileKey) ?? false) && currentFiles.length === 0; // Format accepted files for the HTML input element const acceptString = field.allowedExtensions .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) .join(","); return (
{/* Field Header */}
Max size: {field.maxSizeMb}MB {field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`}
{/* Help / Description Text */} {field.helpText && (

{field.helpText}

)} {/* Selected Files List */} {currentFiles.length > 0 && (
{currentFiles.map((fileObj, idx) => (

{fileObj.name}

{formatBytes(fileObj.size)} Ready
{/* Hidden inputs to represent file details in traditional form submissions */}
))}
)} {/* Dropzone area */} {!reachedLimit && (variant === "minimal" ? (
{ if (fileInputRefs.current) { fileInputRefs.current[field.fileKey] = el; } }} multiple={field.isMultiple} accept={acceptString} disabled={disabled} onChange={(e) => handleFileSelect(e, field)} className="hidden" /> Accepts:{" "} {field.allowedExtensions.join(", ").toUpperCase() || "All"}
) : isUploaded ? ( // Uploaded state: a solid success panel that still doubles as a // replace target (click anywhere or drag a new file onto it).
handleDrag(e, field.fileKey, true)} onDragLeave={(e) => handleDrag(e, field.fileKey, false)} onDrop={(e) => handleDrop(e, field)} className={cn( "group relative flex items-center gap-4 rounded-lg border p-4 transition-all", isDragOver ? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10" : "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10", disabled && "opacity-50 pointer-events-none cursor-not-allowed", )} > handleFileSelect(e, field)} id={`file-input-${field.fileKey}`} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" aria-label={`Replace ${field.fileLabel}`} />
{isDragOver ? ( ) : ( )}

{isDragOver ? "Drop to replace" : "Document uploaded"}

{isDragOver ? "Release to replace the document on file." : "Saved to your application. Drag a new file here or click to replace it."}

Replace
) : (
handleDrag(e, field.fileKey, true)} onDragLeave={(e) => handleDrag(e, field.fileKey, false)} onDrop={(e) => handleDrop(e, field)} className={cn( "relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50", isDragOver ? "border-primary bg-primary/5 dark:bg-primary/10" : "border-border hover:border-primary/50 hover:bg-muted/10", fieldError && "border-destructive hover:border-destructive/80", disabled && "opacity-50 pointer-events-none cursor-not-allowed", )} > handleFileSelect(e, field)} id={`file-input-${field.fileKey}`} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" />

Drag & drop your file here, or{" "} browse

Supported formats:{" "} {field.allowedExtensions.join(", ").toUpperCase() || "All"}

))} {/* Validation Error Message */} {fieldError && (
{fieldError}
)}
); })}
); } export default SmartFileInput;