pmtool / src /components /tasks /UploadAttachmentDialog.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
15.3 kB
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;