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"; import type { ViewableFile } from "../FileViewer"; 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[]; /** * Metadata for already-uploaded files, keyed by fileKey. When a field has * entries here, they're listed with view/download links (instead of the * generic placeholder text) and the field counts as uploaded even if it's * not also listed in `uploadedKeys`. */ existingFiles?: Record< string, { name: string; url: string; size?: number; mimeType?: string | null }[] >; /** * When provided, already-uploaded files render as buttons that call this with * the file instead of opening a new browser tab. Wire it to `useFileViewer`'s * `view` to preview documents inline: * * const { view, viewer } = useFileViewer(); * * {viewer} */ onViewFile?: (file: ViewableFile) => void; /** 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 ; } type ExistingFile = { name: string; url: string; size?: number; mimeType?: string | null; }; /** * A single already-uploaded file. Renders a click-to-view button when * `onViewFile` is set (inline preview via the FileViewer), otherwise a plain * new-tab anchor. */ function ExistingFileLink({ file: f, onViewFile, className, showSize = false, }: { file: ExistingFile; onViewFile?: (file: ViewableFile) => void; className?: string; showSize?: boolean; }) { const label = showSize && typeof f.size === "number" ? `${f.name} (${formatBytes(f.size)})` : f.name; if (onViewFile) { return ( ); } return ( e.stopPropagation()} className={cn( "relative z-10 text-xs text-primary hover:underline truncate max-w-xs", className, )} > {label} ); } /** * A file the user just picked (in memory, not yet persisted). Rendered with a * subtle "just added" entrance + an emerald accent so a fresh upload reads as * distinct from the neutral surrounding surface. */ function NewFileCard({ file: fileObj, onRemove, disabled, hasError, inputName, }: { file: File; onRemove: () => void; disabled?: boolean; hasError?: boolean; inputName: string; }) { return (

{fileObj.name}

{formatBytes(fileObj.size)} Ready to upload
{/* Hidden input to represent file details in traditional form submissions */}
); } export function SmartFileInput({ file, value, onChange, errors, uploadedKeys, existingFiles, onViewFile, 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]; const existingForField = existingFiles?.[field.fileKey] ?? []; // Already uploaded server-side and nothing newly picked to replace it. const isUploaded = ((uploadedKeys?.includes(field.fileKey) ?? false) || existingForField.length > 0) && 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}

)} {/* Multiple-file fields (default variant) render as ONE integrated drag-and-drop surface. Uploaded files live INSIDE the dropzone as lightweight rows — part of the surface, not separate cards — with the "add more" prompt on the same surface below them. A full-cover transparent input makes clicking anywhere (outside a file row) open the picker; the prompt is pointer-transparent so clicks fall through to it, while file rows and their controls sit above it. */} {variant === "default" && field.isMultiple ? (
handleDrag(e, field.fileKey, true)} onDragLeave={(e) => handleDrag(e, field.fileKey, false)} onDrop={(e) => handleDrop(e, field)} className={cn( "relative flex flex-col gap-2.5 rounded-xl border-2 border-dashed p-4 transition-all", isDragOver ? "border-primary bg-primary/5 dark:bg-primary/10" : fieldError ? "border-destructive/70" : "border-border bg-card/40 hover:border-primary/40", disabled && "pointer-events-none opacity-50", )} > {/* Click anywhere on the surface (except a file row) to browse */} {!reachedLimit && ( handleFileSelect(e, field)} className="absolute inset-0 z-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed" aria-label={`Add files to ${field.fileLabel}`} /> )} {(existingForField.length > 0 || currentFiles.length > 0) && (
{/* Already-saved (server) files — view/download only */} {existingForField.map((f, idx) => (
Saved
))} {/* Just-added (in-memory) files */} {currentFiles.map((fileObj, idx) => (

{fileObj.name}

{formatBytes(fileObj.size)}

Ready
))}
)} {reachedLimit ? (
Maximum of {maxFiles} files reached
) : (
0 || currentFiles.length > 0 ? "py-1" : "py-6", )} >
0 || currentFiles.length > 0 ? "p-1.5" : "p-3", )} > 0 || currentFiles.length > 0 ? "h-4 w-4" : "h-6 w-6", isDragOver && "animate-bounce text-primary", )} />

{isDragOver ? "Drop your files here" : existingForField.length > 0 || currentFiles.length > 0 ? "Add more files, or " : "Drag & drop your files here, or "} {!isDragOver && ( browse )}

{field.allowedExtensions.join(", ").toUpperCase() || "All formats"} {" • "} {currentFiles.length}/{maxFiles} added

)}
) : ( <> {/* Selected Files List */} {currentFiles.length > 0 && (
{currentFiles.map((fileObj, idx) => ( removeFile(field.fileKey, idx)} /> ))}
)} {/* 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"} {existingForField.length > 0 && (
{existingForField.map((f, idx) => ( ))}
)}
) : 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"}

{existingForField.length > 0 ? (
{existingForField.map((f, idx) => ( ))}
) : (

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

)}
Replace file
) : (
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;