pmtool / src /components /tasks /TaskAttachmentsSection.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
23.1 kB
import React, { useState, useEffect, useRef } from "react";
import { Attachment, attachmentApi } from "@/services/attachmentApi";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardFooter
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
FileUp,
Download,
Trash2,
File,
FileText,
Image,
Film,
Music,
Archive,
FileX,
Loader2,
Plus,
Eye,
Info
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "../ui/badge";
import { cn } from "@/lib/utils";
import { Progress } from "@/components/ui/progress";
import AttachmentViewerDialog from "./AttachmentViewerDialog";
import UploadAttachmentDialog, { FileUploadData } from "./UploadAttachmentDialog";
interface TaskAttachmentsSectionProps {
taskId: number;
entityType?: "Task" | "Issue" | "Project";
}
const TaskAttachmentsSection: React.FC<TaskAttachmentsSectionProps> = ({
taskId,
entityType = "Task"
}) => {
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [downloadingFiles, setDownloadingFiles] = useState<Record<number, boolean>>({});
const [downloadProgress, setDownloadProgress] = useState<Record<number, number>>({});
const [downloadSizes, setDownloadSizes] = useState<Record<number, number>>({});
const [viewerOpen, setViewerOpen] = useState(false);
const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
// New states for the upload dialog
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<FileUploadData[]>([]);
const desktopFileInputRef = useRef<HTMLInputElement>(null);
const mobileFileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (taskId) {
fetchAttachments();
}
}, [taskId, entityType]);
const fetchAttachments = async () => {
try {
setIsLoading(true);
const data = await attachmentApi.getByEntityTypeAndId(entityType, taskId);
setAttachments(data);
} catch (error) {
console.error(`Error fetching attachments for ${entityType}:`, error);
toast.error(`Failed to load attachments for ${entityType}`);
} finally {
setIsLoading(false);
}
};
// Handle file selection for the upload dialog
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
// Convert FileList to array of FileUploadData
const newFiles = Array.from(files).map(file => ({
file,
description: ""
}));
setSelectedFiles(newFiles);
setUploadDialogOpen(true);
// Reset the file input
if (e.target === desktopFileInputRef.current) {
desktopFileInputRef.current.value = "";
} else if (e.target === mobileFileInputRef.current) {
mobileFileInputRef.current.value = "";
}
};
// Update description for a file
const updateFileDescription = (index: number, description: string) => {
setSelectedFiles(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], description };
return updated;
});
};
// Handle the actual file upload
const handleUploadFiles = async () => {
if (selectedFiles.length === 0) return;
try {
setIsUploading(true);
for (const fileData of selectedFiles) {
const { file, description } = fileData;
const formData = new FormData();
// Add file data
formData.append("AttachmentId", "0");
formData.append("FileName", file.name);
formData.append("FileType", file.type || getFileExtension(file.name));
formData.append("FilePath", ""); // Server will set this
formData.append("EntityType", entityType);
formData.append("EntityId", taskId.toString());
formData.append("Description", description); // Add description
formData.append("_IFormFile", file);
await attachmentApi.upload(formData);
}
// Refresh the list after upload
fetchAttachments();
toast.success("File(s) uploaded successfully");
// Reset state
setSelectedFiles([]);
setUploadDialogOpen(false);
} catch (error) {
console.error("Error uploading file:", error);
toast.error("Failed to upload file");
} finally {
setIsUploading(false);
}
};
const handleDeleteAttachment = async (attachmentId: number) => {
if (window.confirm("Are you sure you want to delete this attachment?")) {
try {
await attachmentApi.delete(attachmentId);
setAttachments(attachments.filter(a => a.attachmentId !== attachmentId));
toast.success("Attachment deleted successfully");
} catch (error) {
console.error("Error deleting attachment:", error);
toast.error("Failed to delete attachment");
}
}
};
const handleDownload = async (attachment: Attachment) => {
try {
// Set this file as downloading and reset progress
setDownloadingFiles(prev => ({ ...prev, [attachment.attachmentId]: true }));
setDownloadProgress(prev => ({ ...prev, [attachment.attachmentId]: 0 }));
setDownloadSizes(prev => ({ ...prev, [attachment.attachmentId]: 0 }));
// Show loading toast
const toastId = toast.loading(`Downloading ${attachment.fileName}...`, {
position: 'bottom-center'
});
// Progress tracking callback
const onProgress = (progress: number, totalSize: number) => {
setDownloadProgress(prev => ({ ...prev, [attachment.attachmentId]: progress }));
setDownloadSizes(prev => ({ ...prev, [attachment.attachmentId]: totalSize }));
};
// Use the enhanced downloadAndSave method directly with progress callback
await attachmentApi.downloadAndSave(attachment.attachmentId, onProgress);
// Dismiss the loading toast and show success
toast.dismiss(toastId);
toast.success(`File downloaded successfully`, {
position: 'bottom-center',
duration: 2000
});
} catch (error) {
console.error('Error downloading file:', error);
toast.error('Failed to download attachment', {
position: 'bottom-center'
});
} finally {
// Clear downloading state for this file
setDownloadingFiles(prev => {
const newState = { ...prev };
delete newState[attachment.attachmentId];
return newState;
});
setDownloadProgress(prev => {
const newState = { ...prev };
delete newState[attachment.attachmentId];
return newState;
});
setDownloadSizes(prev => {
const newState = { ...prev };
delete newState[attachment.attachmentId];
return newState;
});
}
};
const handleViewAttachment = (attachment: Attachment) => {
setSelectedAttachment(attachment);
setViewerOpen(true);
};
// 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 = (attachment: Attachment) => {
const fileType = attachment.fileType.toLowerCase();
if (fileType.includes('image') || fileType.includes('.jpg') || fileType.includes('.jpeg') ||
fileType.includes('.png') || fileType.includes('.gif') || fileType.includes('.webp')) {
return <Image className="h-5 w-5 text-purple-500" />;
}
if (fileType.includes('pdf')) {
return <FileText className="h-5 w-5 text-red-500" />;
}
if (fileType.includes('word') || fileType.includes('.doc') || fileType.includes('.docx')) {
return <FileText className="h-5 w-5 text-blue-500" />;
}
if (fileType.includes('excel') || fileType.includes('.xls') || fileType.includes('.xlsx')) {
return <FileText className="h-5 w-5 text-green-500" />;
}
if (fileType.includes('video') || fileType.includes('.mp4') || fileType.includes('.mov')) {
return <Film className="h-5 w-5 text-orange-500" />;
}
if (fileType.includes('audio') || fileType.includes('.mp3') || fileType.includes('.wav')) {
return <Music className="h-5 w-5 text-blue-500" />;
}
if (fileType.includes('zip') || fileType.includes('.rar') || fileType.includes('.7z')) {
return <Archive className="h-5 w-5 text-yellow-500" />;
}
return <File className="h-5 w-5 text-gray-500" />;
};
// Helper to format file size
const formatFileSize = (bytes: number): string => {
if (!bytes) return 'Unknown size';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
// Helper to format file name for display
const formatDisplayName = (fileName: string, maxLength: number = 30): string => {
if (fileName.length <= maxLength) return fileName;
const extension = fileName.slice((fileName.lastIndexOf(".") - 1 >>> 0) + 1);
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf("."));
if (extension.length + 5 >= maxLength) {
return `${nameWithoutExt.slice(0, maxLength - 10)}...${extension}`;
}
const charsToCut = fileName.length - maxLength + 3;
return `${nameWithoutExt.slice(0, nameWithoutExt.length - charsToCut)}...${extension}`;
};
// Helper to determine a shorter max length for mobile screens
const getResponsiveMaxLength = (): number => {
if (typeof window !== 'undefined') {
return window.innerWidth < 640 ? 15 : 30;
}
return 30;
};
return (
<div className="w-full">
{/* Attachment Viewer Dialog */}
<AttachmentViewerDialog
isOpen={viewerOpen}
onClose={() => {
setViewerOpen(false);
setSelectedAttachment(null);
}}
attachment={selectedAttachment}
onDownload={handleDownload}
/>
{/* Custom Upload Dialog */}
<UploadAttachmentDialog
isOpen={uploadDialogOpen}
onClose={() => setUploadDialogOpen(false)}
selectedFiles={selectedFiles}
onUpdateDescription={updateFileDescription}
onUpload={handleUploadFiles}
isUploading={isUploading}
entityType={entityType}
/>
{/* Fixed Action Button for Mobile */}
<div className="sm:hidden fixed bottom-6 right-6 z-10">
<Label htmlFor="mobile-file-upload" className="cursor-pointer">
<div className="h-14 w-14 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-lg">
<Plus className="h-6 w-6" />
</div>
<Input
id="mobile-file-upload"
ref={mobileFileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={isUploading}
/>
</Label>
</div>
<Card className="shadow-sm border">
<CardHeader className="pb-3">
<div className="flex justify-between items-center">
<CardTitle className="text-xl flex items-center gap-2">
<FileText className="h-5 w-5" />
Attachments
</CardTitle>
<div className="hidden sm:block">
<Label htmlFor="desktop-file-upload" className="cursor-pointer">
<div className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md text-sm border",
isUploading
? "bg-muted text-muted-foreground"
: "bg-white hover:bg-accent transition-colors"
)}>
{isUploading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Uploading...</span>
</>
) : (
<>
<FileUp className="h-4 w-4" />
<span>Upload</span>
</>
)}
</div>
<Input
id="desktop-file-upload"
ref={desktopFileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={isUploading}
/>
</Label>
</div>
</div>
<CardDescription>
Files and documents related to this {entityType.toLowerCase()}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center items-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : attachments.length === 0 ? (
<div className="text-center py-8 border border-dashed rounded-lg flex flex-col items-center">
<FileX className="h-10 w-10 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground mb-2">No attachments yet</p>
<Label htmlFor="empty-file-upload" className="cursor-pointer sm:block hidden">
<div className="bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm hover:bg-primary/90 transition-colors">
Upload Files
</div>
<Input
id="empty-file-upload"
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={isUploading}
/>
</Label>
<p className="text-xs text-muted-foreground mt-2 sm:hidden">
Tap the + button to upload files
</p>
</div>
) : (
<div className="space-y-4">
<div className="grid gap-3">
{attachments.map((attachment) => (
<div
key={attachment.attachmentId}
className={cn(
"flex items-center justify-between border rounded-lg p-3 bg-card transition-colors",
downloadingFiles[attachment.attachmentId]
? "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800"
: "hover:bg-accent/50"
)}
>
<div
className="flex items-center gap-2 sm:gap-3 overflow-hidden flex-1 min-w-0 cursor-pointer"
onClick={() => handleViewAttachment(attachment)}
>
<div className={cn(
"p-2 rounded-md flex-shrink-0",
downloadingFiles[attachment.attachmentId]
? "bg-blue-100 dark:bg-blue-800/30"
: "bg-accent"
)}>
{downloadingFiles[attachment.attachmentId] ? (
<Loader2 className="h-5 w-5 text-blue-600 dark:text-blue-400 animate-spin" />
) : (
getFileIcon(attachment)
)}
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate" title={attachment.fileName}>
{formatDisplayName(attachment.fileName, getResponsiveMaxLength())}
</p>
{/* Display the description if it exists */}
{attachment.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate" title={attachment.description}>
<Info className="h-3 w-3 inline mr-1" />
{attachment.description}
</p>
)}
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className={cn(
"px-1.5 py-0 h-5 text-xs",
downloadingFiles[attachment.attachmentId]
? "bg-blue-100 dark:bg-blue-800/30 text-blue-600 dark:text-blue-400"
: "bg-accent"
)}>
{downloadingFiles[attachment.attachmentId] ? "Downloading..." : attachment.fileType || getFileExtension(attachment.fileName)}
</Badge>
{!downloadingFiles[attachment.attachmentId] && (
<span className="text-xs text-muted-foreground">
{attachment.createdBy && `Added by ${attachment.createdBy}`}
{attachment.createdAt && attachment.createdBy && ' · '}
{attachment.createdAt && new Date(attachment.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
)}
{downloadingFiles[attachment.attachmentId] && (
<div className="mt-2 w-full">
<Progress
value={downloadProgress[attachment.attachmentId] || 0}
className="h-1.5 bg-blue-100 dark:bg-blue-900/30"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{downloadProgress[attachment.attachmentId] || 0}%</span>
{downloadSizes[attachment.attachmentId] && (
<span>{formatFileSize(downloadSizes[attachment.attachmentId])}</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleViewAttachment(attachment);
}}
title="View attachment"
className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
disabled={downloadingFiles[attachment.attachmentId]}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDownload(attachment);
}}
title="Download file"
className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
disabled={downloadingFiles[attachment.attachmentId]}
>
{downloadingFiles[attachment.attachmentId] ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDeleteAttachment(attachment.attachmentId);
}}
title="Delete attachment"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
disabled={downloadingFiles[attachment.attachmentId]}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
<CardFooter className="border-t pt-4 flex justify-between">
<div className="text-sm text-muted-foreground">
{attachments.length} attachment{attachments.length !== 1 ? 's' : ''}
</div>
<Label htmlFor="footer-file-upload" className="cursor-pointer hidden sm:block">
<div className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm border ${
isUploading ? 'bg-muted text-muted-foreground' : 'bg-white hover:bg-accent transition-colors'
}`}>
{isUploading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Uploading...</span>
</>
) : (
<>
<FileUp className="h-4 w-4" />
<span>Upload Files</span>
</>
)}
</div>
<Input
id="footer-file-upload"
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={isUploading}
/>
</Label>
</CardFooter>
</Card>
</div>
);
};
export default TaskAttachmentsSection;