Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { Button } from "@/components/ui/button"; | |
| import { Label } from "@/components/ui/label"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { FileUp, Loader2, File, FileText, Image, Film, Music, Archive, ChevronDown, Search, HelpCircle } from "lucide-react"; | |
| import CustomDialog from '@/components/CustomDialog'; | |
| import { Input } from "@/components/ui/input"; | |
| import { cn } from "@/lib/utils"; | |
| import { | |
| Tooltip, | |
| TooltipContent, | |
| TooltipProvider, | |
| TooltipTrigger, | |
| } from "@/components/ui/tooltip"; | |
| export interface FileUploadData { | |
| file: File; | |
| description: string; | |
| documentType?: string; // Optional document type field | |
| } | |
| // Document type interface | |
| export interface DocumentType { | |
| name: string; | |
| description: string; | |
| } | |
| interface UploadAttachmentDialogProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| selectedFiles: FileUploadData[]; | |
| onUpdateDescription: (index: number, description: string) => void; | |
| onUpdateDocumentType?: (index: number, documentType: string) => void; // Optional handler for document type | |
| onUpload: () => void; | |
| isUploading: boolean; | |
| entityType: string; | |
| documentTypes?: DocumentType[] | string[]; // Can accept either structure | |
| } | |
| /** | |
| * A custom dialog component for uploading attachments with descriptions | |
| */ | |
| const UploadAttachmentDialog: React.FC<UploadAttachmentDialogProps> = ({ | |
| isOpen, | |
| onClose, | |
| selectedFiles, | |
| onUpdateDescription, | |
| onUpdateDocumentType, | |
| onUpload, | |
| isUploading, | |
| entityType, | |
| documentTypes = [] | |
| }) => { | |
| // State for custom dropdowns | |
| const [openDropdowns, setOpenDropdowns] = useState<Record<number, boolean>>({}); | |
| const dropdownRefs = useRef<Record<number, HTMLDivElement | null>>({}); | |
| const [dropdownPositions, setDropdownPositions] = useState<Record<number, 'top' | 'bottom'>>({}); | |
| const [dropdownWidths, setDropdownWidths] = useState<Record<number, number>>({}); | |
| const [searchQueries, setSearchQueries] = useState<Record<number, string>>({}); | |
| // Check if documentTypes has the new structure | |
| const hasDetailedDocTypes = documentTypes.length > 0 && | |
| typeof documentTypes[0] !== 'string' && | |
| 'name' in documentTypes[0]; | |
| // Helper to get file extension from filename | |
| const getFileExtension = (filename: string): string => { | |
| return filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2); | |
| }; | |
| // Helper to get file icon based on file type | |
| const getFileIcon = (file: File) => { | |
| const fileType = file.type.toLowerCase(); | |
| const fileName = file.name.toLowerCase(); | |
| if (fileType.includes('image') || fileName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) { | |
| return <Image className="h-5 w-5 text-purple-500" />; | |
| } | |
| if (fileType.includes('pdf') || fileName.endsWith('.pdf')) { | |
| return <FileText className="h-5 w-5 text-red-500" />; | |
| } | |
| if (fileType.includes('word') || fileName.match(/\.(doc|docx)$/i)) { | |
| return <FileText className="h-5 w-5 text-blue-500" />; | |
| } | |
| if (fileType.includes('excel') || fileName.match(/\.(xls|xlsx)$/i)) { | |
| return <FileText className="h-5 w-5 text-green-500" />; | |
| } | |
| if (fileType.includes('video') || fileName.match(/\.(mp4|mov|avi|wmv)$/i)) { | |
| return <Film className="h-5 w-5 text-orange-500" />; | |
| } | |
| if (fileType.includes('audio') || fileName.match(/\.(mp3|wav|ogg)$/i)) { | |
| return <Music className="h-5 w-5 text-blue-500" />; | |
| } | |
| if (fileType.includes('zip') || fileName.match(/\.(zip|rar|7z|tar|gz)$/i)) { | |
| return <Archive className="h-5 w-5 text-yellow-500" />; | |
| } | |
| return <File className="h-5 w-5 text-gray-500" />; | |
| }; | |
| const handleCancel = () => { | |
| if (!isUploading) { | |
| onClose(); | |
| } | |
| }; | |
| // Get normalized document types for filtering | |
| const getNormalizedDocTypes = (): Array<{ name: string, description: string }> => { | |
| if (hasDetailedDocTypes) { | |
| return documentTypes as DocumentType[]; | |
| } else { | |
| return (documentTypes as string[]).map(name => ({ | |
| name, | |
| description: "" | |
| })); | |
| } | |
| }; | |
| // Determine dropdown position when toggling open | |
| const toggleDropdown = (index: number) => { | |
| if (isUploading) return; | |
| if (!openDropdowns[index]) { | |
| // Only calculate position when opening | |
| const dropdownRef = dropdownRefs.current[index]; | |
| if (dropdownRef) { | |
| const rect = dropdownRef.getBoundingClientRect(); | |
| const spaceBelow = window.innerHeight - rect.bottom; | |
| const requiredSpace = Math.min(documentTypes.length * 28, 200) + 50; // Extra space for search box | |
| setDropdownPositions(prev => ({ | |
| ...prev, | |
| [index]: spaceBelow < requiredSpace ? 'top' : 'bottom' | |
| })); | |
| // Store the exact width | |
| setDropdownWidths(prev => ({ | |
| ...prev, | |
| [index]: rect.width | |
| })); | |
| } | |
| } | |
| setOpenDropdowns(prev => ({ | |
| ...prev, | |
| [index]: !prev[index] | |
| })); | |
| // Reset search when opening | |
| if (!openDropdowns[index]) { | |
| setSearchQueries(prev => ({ | |
| ...prev, | |
| [index]: '' | |
| })); | |
| } | |
| }; | |
| // Update search query | |
| const handleSearchChange = (index: number, value: string) => { | |
| setSearchQueries(prev => ({ | |
| ...prev, | |
| [index]: value | |
| })); | |
| }; | |
| // Handle selecting a document type | |
| const handleSelectDocumentType = (index: number, type: string) => { | |
| if (onUpdateDocumentType) { | |
| onUpdateDocumentType(index, type); | |
| } | |
| // Close the dropdown | |
| setOpenDropdowns(prev => ({ | |
| ...prev, | |
| [index]: false | |
| })); | |
| }; | |
| // Filter document types based on search query | |
| const getFilteredDocumentTypes = (index: number) => { | |
| const query = searchQueries[index] || ''; | |
| const normalizedDocTypes = getNormalizedDocTypes(); | |
| if (!query) return normalizedDocTypes; | |
| return normalizedDocTypes.filter(docType => | |
| docType.name.toLowerCase().includes(query.toLowerCase()) || | |
| docType.description.toLowerCase().includes(query.toLowerCase()) | |
| ); | |
| }; | |
| // Get document type name to display in dropdown | |
| const getDocumentTypeDisplay = (fileData: FileUploadData) => { | |
| if (!fileData.documentType) return "Select document type"; | |
| // If using new structure, find the matching document type | |
| if (hasDetailedDocTypes) { | |
| const docTypes = documentTypes as DocumentType[]; | |
| const foundType = docTypes.find(dt => dt.name === fileData.documentType); | |
| return foundType ? foundType.name : fileData.documentType; | |
| } | |
| return fileData.documentType; | |
| }; | |
| // Close dropdowns when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| Object.entries(dropdownRefs.current).forEach(([indexStr, ref]) => { | |
| const index = parseInt(indexStr); | |
| if (ref && !ref.contains(event.target as Node) && openDropdowns[index]) { | |
| setOpenDropdowns(prev => ({ | |
| ...prev, | |
| [index]: false | |
| })); | |
| } | |
| }); | |
| }; | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| }, [openDropdowns]); | |
| // Determine if we should show document type selection | |
| const showDocumentTypes = documentTypes.length > 0 && onUpdateDocumentType; | |
| // Validation: Check if all files have a description and at least one file is selected | |
| const isFormValid = selectedFiles.length > 0 && | |
| selectedFiles.every(fileData => fileData.description.trim() !== ''); | |
| return ( | |
| <CustomDialog | |
| isOpen={isOpen} | |
| onClose={handleCancel} | |
| title={`Upload ${entityType} Documents`} | |
| description={`Add files to this ${entityType.toLowerCase()}. You can provide a description for each file.`} | |
| allowClose={!isUploading} | |
| isPending={isUploading} | |
| > | |
| <div className="space-y-4 py-2"> | |
| {selectedFiles.map((fileData, index) => ( | |
| <div key={index} className="space-y-2 border rounded-lg p-3 relative"> | |
| <div className="flex items-center gap-2"> | |
| {getFileIcon(fileData.file)} | |
| <span className="text-sm font-medium">{fileData.file.name}</span> | |
| <span className="text-xs text-muted-foreground ml-auto"> | |
| {(fileData.file.size / 1024).toFixed(1)} KB | |
| </span> | |
| </div> | |
| {/* Custom Document Type Dropdown - Only show if document types are provided */} | |
| {showDocumentTypes && ( | |
| <div className="space-y-1"> | |
| <Label htmlFor={`doctype-${index}`} className="text-xs">Document Type</Label> | |
| <div | |
| ref={ref => dropdownRefs.current[index] = ref} | |
| className="relative" | |
| > | |
| <div | |
| className={cn( | |
| "flex items-center justify-between rounded-md border border-input px-3 py-2 text-sm ring-offset-background", | |
| isUploading ? "bg-muted text-muted-foreground" : "bg-transparent", | |
| "cursor-pointer select-none" | |
| )} | |
| onClick={() => toggleDropdown(index)} | |
| id={`doctype-${index}`} | |
| > | |
| <span className={fileData.documentType ? "text-foreground" : "text-muted-foreground"}> | |
| {getDocumentTypeDisplay(fileData)} | |
| </span> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </div> | |
| {openDropdowns[index] && ( | |
| <div | |
| className={cn( | |
| "fixed z-[1010] rounded-md border bg-popover shadow-md", | |
| dropdownPositions[index] === 'top' | |
| ? "bottom-full mb-1" // Position above the trigger | |
| : "mt-1" // Position below the trigger | |
| )} | |
| style={{ | |
| width: `${dropdownWidths[index]}px`, | |
| maxHeight: '250px', | |
| overflowY: 'auto', | |
| left: dropdownRefs.current[index]?.getBoundingClientRect().left, | |
| [dropdownPositions[index] === 'top' ? 'bottom' : 'top']: | |
| dropdownPositions[index] === 'top' | |
| ? window.innerHeight - dropdownRefs.current[index]?.getBoundingClientRect().top | |
| : dropdownRefs.current[index]?.getBoundingClientRect().bottom | |
| }} | |
| > | |
| <div className="p-2 border-b sticky top-0 bg-popover"> | |
| <div className="relative"> | |
| <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search document types..." | |
| value={searchQueries[index] || ''} | |
| onChange={(e) => handleSearchChange(index, e.target.value)} | |
| className="pl-8 h-8 text-sm" | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| </div> | |
| </div> | |
| <div className="p-1"> | |
| {getFilteredDocumentTypes(index).length > 0 ? ( | |
| getFilteredDocumentTypes(index).map((docType) => ( | |
| <TooltipProvider key={docType.name} delayDuration={300}> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <div | |
| className={cn( | |
| "flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none", | |
| "hover:bg-accent hover:text-accent-foreground", | |
| fileData.documentType === docType.name && "bg-accent/50" | |
| )} | |
| onClick={() => handleSelectDocumentType(index, docType.name)} | |
| > | |
| <span className="flex-1 truncate">{docType.name}</span> | |
| {docType.description && ( | |
| <HelpCircle className="h-3.5 w-3.5 ml-1 text-muted-foreground opacity-70" /> | |
| )} | |
| </div> | |
| </TooltipTrigger> | |
| {docType.description && ( | |
| <TooltipContent className="max-w-xs p-2 text-xs" side="right"> | |
| {docType.description} | |
| </TooltipContent> | |
| )} | |
| </Tooltip> | |
| </TooltipProvider> | |
| )) | |
| ) : ( | |
| <div className="px-2 py-3 text-sm text-muted-foreground text-center"> | |
| No matching document types | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| <div className="space-y-1"> | |
| <Label htmlFor={`desc-${index}`} className="text-xs ">Description <span className="text-red-500">*</span></Label> | |
| <Textarea | |
| id={`desc-${index}`} | |
| placeholder="Add a description for this file..." | |
| value={fileData.description} | |
| onChange={(e) => onUpdateDescription(index, e.target.value)} | |
| className="resize-none h-20 text-sm" | |
| disabled={isUploading} | |
| /> | |
| </div> | |
| <div> | |
| <p className="text-xs text-muted-foreground text-red-500 ">*Required</p> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="flex justify-end gap-2 mt-6 pt-4 border-t"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| onClick={handleCancel} | |
| disabled={isUploading} | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| type="button" | |
| onClick={onUpload} | |
| disabled={isUploading || !isFormValid} | |
| className="min-w-24" | |
| > | |
| {isUploading ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 animate-spin mr-2" /> | |
| Uploading... | |
| </> | |
| ) : ( | |
| <> | |
| <FileUp className="h-4 w-4 mr-2" /> | |
| Upload | |
| </> | |
| )} | |
| </Button> | |
| </div> | |
| </CustomDialog> | |
| ); | |
| }; | |
| export default UploadAttachmentDialog; |