pmtool / src /components /employees /EmployeeAttachmentsSection.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
30 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,
Lock
} 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 "../tasks/AttachmentViewerDialog";
import { useAuth } from "@/lib/auth-context";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import EmployeeDocumentSelector, { DocumentType } from "./EmployeeDocumentSelector";
// Predefined document types for employee attachments
export const EMPLOYEE_DOCUMENT_TYPES = [
{
name: "Date of Birth Certificate",
description: "Copy of the official certificate proving your date of birth."
},
{
name: "Educational Certificate – SSC",
description: "Copy of your Secondary School Certificate (10th Grade)."
},
{
name: "Educational Certificate – HSC",
description: "Copy of your Higher Secondary Certificate (12th Grade)."
},
{
name: "Educational Certificate – Degree",
description: "Copy of your Graduation/Post-Graduation Degree certificate."
},
{
name: "Educational Certificate – Mark Sheets",
description: "Copies of your semester-wise mark sheets for all degrees."
},
{
name: "Project Certificate",
description: "Certificate for any commercial or academic projects completed as part of your degree course."
},
{
name: "Previous Employment Certificate",
description: "Service certificates from all your previous employers, including employment dates."
},
{
name: "Relieving Letter",
description: "Relieving letter from your current/last employer to confirm your exit."
},
{
name: "Service Certificate",
description: "Service certificate from your current/last employer."
},
{
name: "Pay Slip / Salary Certificate",
description: "Copy of your latest pay slip or salary certificate from your current/last employer."
},
{
name: "Passport Size Photograph",
description: "Two recent passport-size colored photographs."
},
{
name: "Passport Copy",
description: "Photocopy of all relevant passport pages, including personal information, issue and expiry dates, and any visa/work permit/entry permit stamps."
},
{
name: "Marriage Certificate",
description: "If married, a copy of your official marriage certificate."
},
{
name: "Spouse's Passport Copy",
description: "Photocopy of relevant pages of your spouse's passport (if applicable)."
},
{
name: "Child's Birth Certificate",
description: "If applicable, birth certificates of your children."
},
{
name: "Driving License",
description: "Photocopy of your two-wheeler or four-wheeler driving license."
},
{
name: "Medical Fitness Certificate",
description: "Original certificate stating your medical fitness from a certified doctor."
},
{
name: "Aadhaar Card",
description: "Photocopy of your Aadhaar card."
},
{
name: "PAN Card",
description: "Photocopy of your PAN card."
},
{
name: "Other",
description: "Any other documents you wish to provide as part of your verification."
}
];
interface EmployeeAttachmentsSectionProps {
employeeId: number;
canDelete?: boolean; // Optional prop to override delete permission
}
const EmployeeAttachmentsSection: React.FC<EmployeeAttachmentsSectionProps> = ({
employeeId,
canDelete
}) => {
const { userData } = useAuth();
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
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);
// State for document selector dialog
const [docSelectorOpen, setDocSelectorOpen] = useState(false);
// Function to open the document selector dialog
const openDocumentSelector = () => {
setDocSelectorOpen(true);
};
// Check if user has permission to delete attachments
const hasDeletePermission = (): boolean => {
if (canDelete !== undefined) return canDelete;
return userData?.roleName === 'HR' || userData?.roleName === 'Admin';
};
useEffect(() => {
if (employeeId) {
fetchAttachments();
}
}, [employeeId]);
const fetchAttachments = async () => {
try {
setIsLoading(true);
const data = await attachmentApi.getByEntityTypeAndId("Employee", employeeId);
setAttachments(data);
} catch (error) {
console.error(`Error fetching attachments for Employee:`, error);
toast.error(`Failed to load employee documents`);
} finally {
setIsLoading(false);
}
};
// Handle document selection and upload
const handleDocumentUpload = async (docType: DocumentType, file: File, description: string) => {
try {
setIsUploading(true);
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", "Employee");
formData.append("EntityId", employeeId.toString());
// Set the document type as the description
const finalDescription = description
? `${docType.name}: ${description}`
: docType.name;
formData.append("Description", finalDescription);
formData.append("_IFormFile", file);
await attachmentApi.upload(formData);
// Refresh the list after upload
fetchAttachments();
toast.success(`${docType.name} uploaded successfully`);
// Close the dialog
setDocSelectorOpen(false);
} catch (error) {
console.error("Error uploading file:", error);
toast.error("Failed to upload document");
} finally {
setIsUploading(false);
}
};
const handleDeleteAttachment = async (attachmentId: number) => {
if (!hasDeletePermission()) {
toast.error("You don't have permission to delete documents");
return;
}
if (window.confirm("Are you sure you want to delete this document?")) {
try {
await attachmentApi.delete(attachmentId);
setAttachments(attachments.filter(a => a.attachmentId !== attachmentId));
toast.success("Document deleted successfully");
} catch (error) {
console.error("Error deleting document:", error);
toast.error("Failed to delete document");
}
}
};
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 document', {
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;
};
// Helper to extract document type from description
const parseDocumentType = (description: string): { type: string | null, rest: string } => {
if (!description) return { type: null, rest: '' };
// Check if the entire description matches a document type
for (const docType of EMPLOYEE_DOCUMENT_TYPES) {
if (description === docType.name) {
return {
type: docType.name,
rest: ''
};
}
}
// Check if description starts with a document type followed by a colon
for (const docType of EMPLOYEE_DOCUMENT_TYPES) {
if (description.startsWith(`${docType.name}:`)) {
return {
type: docType.name,
rest: description.substring(docType.name.length + 1).trim()
};
}
}
return { type: null, rest: description };
};
// Helper to get color for document type badge
const getDocumentBadgeColor = (documentType: string): string => {
if (documentType.includes('Educational')) return "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800";
if (documentType.includes('Passport')) return "bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-800";
if (documentType.includes('Certificate')) return "bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800";
if (documentType.includes('Aadhaar') || documentType.includes('PAN')) return "bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800";
if (documentType.includes('License')) return "bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800";
if (documentType.includes('Employment') || documentType.includes('Relieving') || documentType.includes('Service')) return "bg-indigo-50 text-indigo-700 border-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-400 dark:border-indigo-800";
if (documentType.includes('Medical')) return "bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800";
if (documentType.includes('Pay') || documentType.includes('Salary')) return "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-900/20 dark:text-cyan-400 dark:border-cyan-800";
return "bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-800/30 dark:text-gray-400 dark:border-gray-700";
};
// Helper to get shortened document type for display
const getShortenedDocumentType = (documentType: string): string => {
// Shortened labels for common document types
if (documentType.includes('Educational Certificate – ')) {
return documentType.replace('Educational Certificate – ', '');
}
// Shorten other common document types
const shortenedMap: Record<string, string> = {
"Date of Birth Certificate": "DOB Cert",
"Passport Size Photograph": "Photo",
"Passport Copy": "Passport",
"Marriage Certificate": "Marriage Cert",
"Spouse's Passport Copy": "Spouse Passport",
"Child's Birth Certificate": "Child Birth Cert",
"Driving License": "Driver License",
"Medical Fitness Certificate": "Medical Cert",
"Previous Employment Certificate": "Prev Employment",
"Pay Slip / Salary Certificate": "Pay Slip"
};
return shortenedMap[documentType] || documentType;
};
return (
<div className="w-full">
{/* Attachment Viewer Dialog */}
<AttachmentViewerDialog
isOpen={viewerOpen}
onClose={() => {
setViewerOpen(false);
setSelectedAttachment(null);
}}
attachment={selectedAttachment}
onDownload={handleDownload}
/>
{/* Document Type Selector Dialog */}
<EmployeeDocumentSelector
isOpen={docSelectorOpen}
onClose={() => setDocSelectorOpen(false)}
documentTypes={EMPLOYEE_DOCUMENT_TYPES}
onSelectAndUpload={handleDocumentUpload}
isUploading={isUploading}
/>
{/* Fixed Action Button for Mobile */}
<div className="sm:hidden fixed bottom-6 right-6 z-10">
<Button
onClick={openDocumentSelector}
className="h-14 w-14 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-lg"
disabled={isUploading}
>
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <Plus className="h-6 w-6" />}
</Button>
</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" />
Documents
</CardTitle>
<div className="hidden sm:block">
<Button
onClick={openDocumentSelector}
variant="outline"
size="sm"
disabled={isUploading}
className="gap-2"
>
{isUploading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Uploading...</span>
</>
) : (
<>
<FileUp className="h-4 w-4" />
<span>Upload Document</span>
</>
)}
</Button>
</div>
</div>
<CardDescription>
Official employee documents and files
</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 documents uploaded yet</p>
<Button
onClick={openDocumentSelector}
variant="default"
className="sm:block hidden"
disabled={isUploading}
>
Upload Documents
</Button>
<p className="text-xs text-muted-foreground mt-2 sm:hidden">
Tap the + button to upload documents
</p>
</div>
) : (
<div className="space-y-4">
<div className="grid gap-3">
{attachments.map((attachment) => {
// Parse the document type from description (if it exists)
const { type: documentType, rest: plainDescription } = parseDocumentType(attachment.description || '');
return (
<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 document type as a badge if it exists */}
<div className="flex items-center gap-2 flex-wrap mt-1">
{documentType && (
<Badge
variant="outline"
className={cn(
"px-2 py-0.5 text-xs font-medium",
getDocumentBadgeColor(documentType)
)}
>
{getShortenedDocumentType(documentType)}
</Badge>
)}
{/* Display the plain description if available */}
{plainDescription && (
<p className="text-xs text-muted-foreground truncate" title={plainDescription}>
<Info className="h-3 w-3 inline mr-1" />
{plainDescription}
</p>
)}
</div>
<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 document"
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 document"
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>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDeleteAttachment(attachment.attachmentId);
}}
title={hasDeletePermission() ? "Delete document" : "You don't have permission to delete"}
className={cn(
"h-8 w-8",
hasDeletePermission()
? "text-red-500 hover:text-red-600 hover:bg-red-50"
: "text-muted-foreground cursor-not-allowed"
)}
disabled={downloadingFiles[attachment.attachmentId] || !hasDeletePermission()}
>
{hasDeletePermission() ? (
<Trash2 className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
{hasDeletePermission() ? "Delete document" : "Only HR and Admin can delete documents"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
})}
</div>
</div>
)}
</CardContent>
<CardFooter className="border-t pt-4 flex justify-between">
<div className="text-sm text-muted-foreground">
{attachments.length} document{attachments.length !== 1 ? 's' : ''}
</div>
<Button
onClick={openDocumentSelector}
variant="outline"
size="sm"
className="hidden sm:flex items-center gap-2"
disabled={isUploading}
>
{isUploading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Uploading...</span>
</>
) : (
<>
<FileUp className="h-4 w-4" />
<span>Upload Document</span>
</>
)}
</Button>
</CardFooter>
</Card>
</div>
);
};
export default EmployeeAttachmentsSection;