Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo, useEffect, useCallback, useRef } from "react"; | |
| import { useNavigate, useLocation } from "react-router-dom"; | |
| import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { UserPlus, Search, Loader2, Eye, Edit, Trash2, Filter, Download, MoreHorizontal, Upload, FileText, Check, X, ChevronDown, Phone, Mail, User, Briefcase } from "lucide-react"; | |
| import { toast } from "@/lib/custom-toast"; | |
| import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; | |
| import { jsPDF } from 'jspdf'; | |
| import autoTable from 'jspdf-autotable'; | |
| import { roleService } from "@/lib/api"; | |
| import { Employee as EmployeeType, Role } from "@/types"; | |
| import { Employee, employeeApi } from "@/services/employeeApi"; | |
| import Header from "@/components/layout/header"; | |
| import Sidebar from "@/components/layout/sidebar"; | |
| import { useIsMobile } from "@/hooks/use-mobile"; | |
| import ImportEmployeesDialog from "@/components/employee/import-employees-dialog"; | |
| import DownloadTemplateButton from "@/components/employee/download-template-button"; | |
| // Update the type declaration | |
| declare module 'jspdf' { | |
| interface jsPDF { | |
| autoTable: (options: any) => jsPDF; | |
| } | |
| } | |
| // Mobile Employee Card Component | |
| interface MobileEmployeeCardProps { | |
| employee: Employee; | |
| getRoleName: (roleId: number) => string; | |
| getRoleBadgeVariant: (roleId: number) => "default" | "secondary" | "outline" | "destructive"; | |
| onView: (id: number) => void; | |
| onEdit: (id: number) => void; | |
| onDelete: (id: number) => void; | |
| onStatusChange: (employee: Employee, status: string) => void; | |
| onReportingChange: (employeeId: number, reportingToId: number | null) => void; | |
| isUpdating: boolean; | |
| getInitials: (firstName: string, lastName: string) => string; | |
| employees: Employee[]; | |
| } | |
| const MobileEmployeeCard = ({ | |
| employee, | |
| getRoleName, | |
| getRoleBadgeVariant, | |
| onView, | |
| onEdit, | |
| onDelete, | |
| onStatusChange, | |
| onReportingChange, | |
| isUpdating, | |
| getInitials, | |
| employees | |
| }: MobileEmployeeCardProps) => { | |
| const [isActionsOpen, setIsActionsOpen] = useState(false); | |
| const actionsRef = useRef<HTMLDivElement>(null); | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (actionsRef.current && !actionsRef.current.contains(event.target as Node)) { | |
| setIsActionsOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| }, []); | |
| const getStatusBadgeVariant = (status: string) => { | |
| switch (status) { | |
| case 'Active': return 'default'; | |
| case 'Inactive': return 'outline'; | |
| case 'On Leave': return 'secondary'; | |
| case 'Terminated': return 'destructive'; | |
| default: return 'outline'; | |
| } | |
| }; | |
| return ( | |
| <Card className="w-full hover:shadow-md transition-shadow"> | |
| <CardContent className="p-4"> | |
| <div className="flex items-start justify-between mb-3"> | |
| <div className="flex items-center gap-3 flex-1 min-w-0"> | |
| <Avatar className="h-12 w-12 flex-shrink-0"> | |
| <AvatarImage src={null} alt={`${employee.firstName} ${employee.lastName}`} /> | |
| <AvatarFallback className="bg-primary/10 text-primary text-sm font-medium"> | |
| {getInitials(employee.firstName, employee.lastName)} | |
| </AvatarFallback> | |
| </Avatar> | |
| <div className="flex-1 min-w-0"> | |
| <h3 className="font-semibold text-base truncate"> | |
| {employee.firstName} {employee.lastName} | |
| </h3> | |
| <p className="text-sm text-muted-foreground truncate"> | |
| {employee.empCode} | |
| </p> | |
| </div> | |
| </div> | |
| {/* Actions Dropdown */} | |
| <div className="relative flex-shrink-0" ref={actionsRef}> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => setIsActionsOpen(!isActionsOpen)} | |
| className="h-8 w-8 p-0" | |
| > | |
| <MoreHorizontal className="h-4 w-4" /> | |
| </Button> | |
| {isActionsOpen && ( | |
| <div className="absolute z-50 top-full right-0 mt-1 w-40 bg-white rounded-md shadow-lg border py-1 animate-in fade-in-0 zoom-in-95"> | |
| <button | |
| type="button" | |
| className="flex items-center w-full text-left px-3 py-2 text-sm hover:bg-gray-100" | |
| onClick={() => { | |
| onView(employee.id); | |
| setIsActionsOpen(false); | |
| }} | |
| > | |
| <Eye className="h-4 w-4 mr-2" /> | |
| View | |
| </button> | |
| <button | |
| type="button" | |
| className="flex items-center w-full text-left px-3 py-2 text-sm hover:bg-gray-100" | |
| onClick={() => { | |
| onEdit(employee.id); | |
| setIsActionsOpen(false); | |
| }} | |
| > | |
| <Edit className="h-4 w-4 mr-2" /> | |
| Edit | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Contact Information */} | |
| <div className="space-y-2 mb-3"> | |
| <div className="flex items-center gap-2 text-sm"> | |
| <Mail className="h-4 w-4 text-muted-foreground flex-shrink-0" /> | |
| <span className="truncate">{employee.email}</span> | |
| </div> | |
| <div className="flex items-center gap-2 text-sm"> | |
| <Phone className="h-4 w-4 text-muted-foreground flex-shrink-0" /> | |
| <span>{employee.mobile}</span> | |
| </div> | |
| </div> | |
| {/* Job Information */} | |
| <div className="flex flex-wrap gap-2 mb-3"> | |
| <div className="flex items-center gap-1 text-xs bg-gray-50 px-2 py-1 rounded"> | |
| <Briefcase className="h-3 w-3 text-muted-foreground" /> | |
| <span>{employee.type}</span> | |
| </div> | |
| <Badge variant={getRoleBadgeVariant(employee.roleId)} className="text-xs"> | |
| {getRoleName(employee.roleId)} | |
| </Badge> | |
| </div> | |
| {/* Status */} | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm text-muted-foreground">Status:</span> | |
| <StatusDropdown | |
| employee={employee} | |
| onStatusChange={onStatusChange} | |
| isUpdating={isUpdating} | |
| /> | |
| </div> | |
| </div> | |
| {/* Reporting */} | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm text-muted-foreground">Reporting To:</span> | |
| <ReportingDropdown | |
| employee={employee} | |
| employees={employees} | |
| onReportingChange={onReportingChange} | |
| isUpdating={isUpdating} | |
| /> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ); | |
| }; | |
| // Custom status dropdown component | |
| interface StatusDropdownProps { | |
| employee: Employee; | |
| onStatusChange: (employee: Employee, status: string) => void; | |
| isUpdating: boolean; | |
| } | |
| const StatusDropdown = ({ employee, onStatusChange, isUpdating }: StatusDropdownProps) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const statusOptions = ['Active', 'Inactive', 'On Leave', 'Terminated', 'Unknown']; | |
| // Close the dropdown when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| }, []); | |
| const getStatusBadgeVariant = (status: string) => { | |
| switch (status) { | |
| case 'Active': return 'default'; | |
| case 'Inactive': return 'outline'; | |
| case 'On Leave': return 'secondary'; | |
| case 'Terminated': return 'destructive'; | |
| default: return 'outline'; | |
| } | |
| }; | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| {/* Status Badge with custom dropdown */} | |
| <div | |
| className="cursor-pointer flex items-center" | |
| onClick={() => !isUpdating && setIsOpen(!isOpen)} | |
| > | |
| <Badge | |
| variant={getStatusBadgeVariant(employee.status || 'Unknown')} | |
| className="mr-1 text-xs" | |
| > | |
| {employee.status || 'Unknown'} | |
| </Badge> | |
| <ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} /> | |
| </div> | |
| {/* Custom dropdown content */} | |
| {isOpen && ( | |
| <div className="absolute z-50 top-full left-0 mt-1 w-[180px] bg-white rounded-md shadow-lg border py-1 animate-in fade-in-0 zoom-in-95"> | |
| <div className="p-2 font-medium text-sm border-b">Update Status</div> | |
| <div className="py-1"> | |
| {statusOptions.map(status => ( | |
| <button | |
| key={status} | |
| type="button" | |
| className={`flex items-center w-full text-left px-3 py-2 text-sm hover:bg-gray-100 | |
| ${employee.status === status ? "bg-primary/10 font-medium" : ""}`} | |
| onClick={() => { | |
| onStatusChange(employee, status); | |
| setIsOpen(false); | |
| }} | |
| > | |
| <Badge | |
| variant={getStatusBadgeVariant(status)} | |
| className="mr-2 text-xs" | |
| > | |
| {status} | |
| </Badge> | |
| {employee.status === status && ( | |
| <Check className="ml-auto h-4 w-4" /> | |
| )} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Loading indicator during update */} | |
| {isUpdating && ( | |
| <div className="absolute inset-0 bg-background/80 flex items-center justify-center"> | |
| <Loader2 className="h-4 w-4 animate-spin text-primary" /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| // Custom reporting dropdown component | |
| interface ReportingDropdownProps { | |
| employee: Employee; | |
| employees: Employee[]; | |
| onReportingChange: (employeeId: number, reportingToId: number | null) => void; | |
| isUpdating: boolean; | |
| } | |
| const ReportingDropdown = ({ employee, employees, onReportingChange, isUpdating }: ReportingDropdownProps) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const triggerRef = useRef<HTMLDivElement>(null); | |
| const searchInputRef = useRef<HTMLInputElement>(null); | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| setSearchQuery(''); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| if (isOpen && searchInputRef.current) { | |
| searchInputRef.current.focus(); | |
| } | |
| if (isOpen && triggerRef.current) { | |
| const rect = triggerRef.current.getBoundingClientRect(); | |
| setDropdownPosition({ | |
| top: rect.bottom + 4, | |
| left: rect.right - 250 // 250px is the dropdown width | |
| }); | |
| } | |
| }, [isOpen]); | |
| const filteredEmployees = employees | |
| .filter(emp => emp.id !== employee.id) | |
| .filter(emp => { | |
| if (!searchQuery) return true; | |
| const fullName = `${emp.firstName} ${emp.lastName}`.toLowerCase(); | |
| return fullName.includes(searchQuery.toLowerCase()); | |
| }); | |
| const getReportingName = (id: number | null | undefined) => { | |
| if (!id) return 'None'; | |
| const reportingTo = employees.find(emp => emp.id === id); | |
| return reportingTo ? `${reportingTo.firstName} ${reportingTo.lastName}` : 'Unknown'; | |
| }; | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <div | |
| ref={triggerRef} | |
| className="cursor-pointer flex items-center" | |
| onClick={() => !isUpdating && setIsOpen(!isOpen)} | |
| > | |
| <span className="mr-1 text-sm">{getReportingName(employee.reportingToId)}</span> | |
| <ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} /> | |
| </div> | |
| {isOpen && ( | |
| <div | |
| className="fixed z-50 w-[250px] bg-white rounded-md shadow-lg border py-1 animate-in fade-in-0 zoom-in-95" | |
| style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }} | |
| > | |
| <div className="p-2 font-medium text-sm border-b">Select Reporting To</div> | |
| <div className="p-2 border-b"> | |
| <div className="relative"> | |
| <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> | |
| <input | |
| ref={searchInputRef} | |
| type="text" | |
| placeholder="Search employees..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="w-full pl-8 pr-3 py-1.5 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary/20" | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| </div> | |
| </div> | |
| <div className="py-1 max-h-60 overflow-y-auto"> | |
| <button | |
| type="button" | |
| className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 ${!employee.reportingToId ? 'bg-primary/10 font-medium' : ''}`} | |
| onClick={() => { | |
| onReportingChange(employee.id, null); | |
| setIsOpen(false); | |
| setSearchQuery(''); | |
| }} | |
| > | |
| None | |
| {!employee.reportingToId && <Check className="ml-auto h-4 w-4" />} | |
| </button> | |
| {filteredEmployees.length > 0 ? ( | |
| filteredEmployees.map(reporting => ( | |
| <button | |
| key={reporting.id} | |
| type="button" | |
| className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 ${employee.reportingToId === reporting.id ? 'bg-primary/10 font-medium' : ''}`} | |
| onClick={() => { | |
| onReportingChange(employee.id, reporting.id); | |
| setIsOpen(false); | |
| setSearchQuery(''); | |
| }} | |
| > | |
| {reporting.firstName} {reporting.lastName} | |
| {employee.reportingToId === reporting.id && <Check className="ml-auto h-4 w-4" />} | |
| </button> | |
| )) | |
| ) : ( | |
| <div className="px-3 py-2 text-sm text-muted-foreground text-center"> | |
| No employees found | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {isUpdating && ( | |
| <div className="absolute inset-0 bg-background/80 flex items-center justify-center"> | |
| <Loader2 className="h-4 w-4 animate-spin text-primary" /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| // Custom export modal component | |
| interface CustomExportModalProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| employees: Employee[]; | |
| getRoleName: (roleId: number) => string; | |
| } | |
| const CustomExportModal = ({ isOpen, onClose, employees, getRoleName }: CustomExportModalProps) => { | |
| const [exportFormat, setExportFormat] = useState<'excel' | 'pdf'>('excel'); | |
| const [selectedColumns, setSelectedColumns] = useState({ | |
| empCode: true, | |
| name: true, | |
| email: true, | |
| mobile: true, | |
| type: true, | |
| role: true, | |
| status: true | |
| }); | |
| // Export to Excel | |
| const exportToExcel = () => { | |
| if (!employees || employees.length === 0) { | |
| toast.error("No data to export"); | |
| return; | |
| } | |
| try { | |
| // Build headers based on selected columns | |
| const headers = []; | |
| if (selectedColumns.empCode) headers.push("Employee Code"); | |
| if (selectedColumns.name) headers.push("Name"); | |
| if (selectedColumns.email) headers.push("Email"); | |
| if (selectedColumns.mobile) headers.push("Mobile"); | |
| if (selectedColumns.type) headers.push("Type"); | |
| if (selectedColumns.role) headers.push("Role"); | |
| if (selectedColumns.status) headers.push("Status"); | |
| // Check if any columns are selected | |
| if (headers.length === 0) { | |
| toast.error("Please select at least one column to export"); | |
| return; | |
| } | |
| // Create HTML table content for proper Excel formatting | |
| let htmlTable = '<table border="1">'; | |
| // Headers row | |
| htmlTable += '<tr>'; | |
| headers.forEach(header => { | |
| htmlTable += `<th>${header}</th>`; | |
| }); | |
| htmlTable += '</tr>'; | |
| // Data rows | |
| employees.forEach(employee => { | |
| htmlTable += '<tr>'; | |
| if (selectedColumns.empCode) htmlTable += `<td>${employee.empCode}</td>`; | |
| if (selectedColumns.name) htmlTable += `<td>${employee.firstName} ${employee.middleName || ''} ${employee.lastName}</td>`; | |
| if (selectedColumns.email) htmlTable += `<td>${employee.email}</td>`; | |
| if (selectedColumns.mobile) htmlTable += `<td>${employee.mobile}</td>`; | |
| if (selectedColumns.type) htmlTable += `<td>${employee.type}</td>`; | |
| if (selectedColumns.role) htmlTable += `<td>${getRoleName(employee.roleId)}</td>`; | |
| if (selectedColumns.status) htmlTable += `<td>${employee.status || 'Unknown'}</td>`; | |
| htmlTable += '</tr>'; | |
| }); | |
| htmlTable += '</table>'; | |
| // Prepare the file | |
| const excelTemplate = ` | |
| <html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40"> | |
| <head> | |
| <!--[if gte mso 9]> | |
| <xml> | |
| <x:ExcelWorkbook> | |
| <x:ExcelWorksheets> | |
| <x:ExcelWorksheet> | |
| <x:Name>Employee Data</x:Name> | |
| <x:WorksheetOptions> | |
| <x:DisplayGridlines/> | |
| </x:WorksheetOptions> | |
| </x:ExcelWorksheet> | |
| </x:ExcelWorksheets> | |
| </x:ExcelWorkbook> | |
| </xml> | |
| <![endif]--> | |
| <style> | |
| table, td, th { | |
| border: 1px solid black; | |
| border-collapse: collapse; | |
| } | |
| th { | |
| background-color: #f2f2f2; | |
| font-weight: bold; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| ${htmlTable} | |
| </body> | |
| </html> | |
| `; | |
| // Create download | |
| const blob = new Blob([excelTemplate], { type: 'application/vnd.ms-excel' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.setAttribute('href', url); | |
| link.setAttribute('download', 'employees.xls'); | |
| link.style.visibility = 'hidden'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| toast.success("Employee data exported to Excel successfully"); | |
| onClose(); | |
| } catch (error) { | |
| console.error("Excel export error:", error); | |
| toast.error("Failed to export data"); | |
| } | |
| }; | |
| // Export to PDF | |
| const exportToPDF = () => { | |
| if (!employees || employees.length === 0) { | |
| toast.error("No data to export"); | |
| return; | |
| } | |
| try { | |
| const doc = new jsPDF(); | |
| // Add title | |
| doc.setFontSize(16); | |
| doc.text('Employee Directory', 14, 15); | |
| doc.setFontSize(10); | |
| doc.text(`Generated on: ${new Date().toLocaleDateString()}`, 14, 22); | |
| // Prepare columns based on selection | |
| const columns = []; | |
| if (selectedColumns.empCode) columns.push('Employee Code'); | |
| if (selectedColumns.name) columns.push('Name'); | |
| if (selectedColumns.email) columns.push('Email'); | |
| if (selectedColumns.mobile) columns.push('Mobile'); | |
| if (selectedColumns.type) columns.push('Type'); | |
| if (selectedColumns.role) columns.push('Role'); | |
| if (selectedColumns.status) columns.push('Status'); | |
| // Check if any columns are selected | |
| if (columns.length === 0) { | |
| toast.error("Please select at least one column to export"); | |
| return; | |
| } | |
| // Prepare data rows | |
| const tableData = employees.map(employee => { | |
| const row = []; | |
| if (selectedColumns.empCode) row.push(employee.empCode); | |
| if (selectedColumns.name) row.push(`${employee.firstName} ${employee.middleName || ''} ${employee.lastName}`); | |
| if (selectedColumns.email) row.push(employee.email); | |
| if (selectedColumns.mobile) row.push(employee.mobile); | |
| if (selectedColumns.type) row.push(employee.type); | |
| if (selectedColumns.role) row.push(getRoleName(employee.roleId)); | |
| if (selectedColumns.status) row.push(employee.status || 'Unknown'); | |
| return row; | |
| }); | |
| // Generate table | |
| autoTable(doc, { | |
| head: [columns], | |
| body: tableData, | |
| startY: 30, | |
| theme: 'grid', | |
| styles: { | |
| fontSize: 8, | |
| cellPadding: 2, | |
| overflow: 'linebreak' | |
| }, | |
| headStyles: { | |
| fillColor: [41, 128, 185], | |
| textColor: 255, | |
| fontSize: 9, | |
| fontStyle: 'bold' | |
| }, | |
| alternateRowStyles: { | |
| fillColor: [245, 245, 245] | |
| } | |
| }); | |
| // Save the PDF | |
| doc.save('employees.pdf'); | |
| toast.success("Employee data exported to PDF successfully"); | |
| onClose(); | |
| } catch (error) { | |
| console.error('PDF export error:', error); | |
| toast.error("Failed to export data to PDF"); | |
| } | |
| }; | |
| // Toggle all columns | |
| const toggleAllColumns = (checked: boolean) => { | |
| setSelectedColumns({ | |
| empCode: checked, | |
| name: checked, | |
| email: checked, | |
| mobile: checked, | |
| type: checked, | |
| role: checked, | |
| status: checked | |
| }); | |
| }; | |
| // Check if any columns are selected | |
| const hasSelectedColumns = Object.values(selectedColumns).some(v => v); | |
| // Prevent export if no columns are selected | |
| const handleExport = () => { | |
| if (!employees || employees.length === 0) { | |
| toast.error("No data to export"); | |
| return; | |
| } | |
| // Validate at least one column is selected | |
| if (!hasSelectedColumns) { | |
| toast.error("Please select at least one column to export"); | |
| return; | |
| } | |
| if (exportFormat === 'excel') { | |
| exportToExcel(); | |
| } else { | |
| exportToPDF(); | |
| } | |
| }; | |
| if (!isOpen) return null; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> | |
| <div className="bg-white rounded-lg shadow-lg w-full max-w-md max-h-[90vh] overflow-hidden"> | |
| <div className="flex items-center justify-between p-4 border-b"> | |
| <h2 className="text-lg sm:text-xl font-semibold">Export Employee Data</h2> | |
| <button | |
| onClick={onClose} | |
| className="text-gray-500 hover:text-gray-700" | |
| > | |
| <X className="h-5 w-5" /> | |
| </button> | |
| </div> | |
| <div className="p-4 overflow-y-auto max-h-[calc(90vh-120px)]"> | |
| <div className="mb-6"> | |
| <p className="font-medium mb-3">Export Format</p> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <label className={`flex items-center space-x-2 cursor-pointer p-3 rounded border ${exportFormat === 'excel' ? 'bg-green-50 border-green-500 ring-1 ring-green-500' : 'bg-white border-gray-300'}`}> | |
| <input | |
| type="radio" | |
| name="exportFormat" | |
| checked={exportFormat === 'excel'} | |
| onChange={() => setExportFormat('excel')} | |
| className="h-4 w-4 text-green-600 border-gray-300 focus:ring-green-500" | |
| /> | |
| <span className="flex items-center"> | |
| <div className="h-5 w-5 bg-green-600 text-white flex items-center justify-center mr-2 text-[10px] font-bold rounded"> | |
| X | |
| </div> | |
| <span className="font-medium">Excel</span> | |
| </span> | |
| </label> | |
| <label className={`flex items-center space-x-2 cursor-pointer p-3 rounded border ${exportFormat === 'pdf' ? 'bg-red-50 border-red-500 ring-1 ring-red-500' : 'bg-white border-gray-300'}`}> | |
| <input | |
| type="radio" | |
| name="exportFormat" | |
| checked={exportFormat === 'pdf'} | |
| onChange={() => setExportFormat('pdf')} | |
| className="h-4 w-4 text-red-600 border-gray-300 focus:ring-red-500" | |
| /> | |
| <span className="flex items-center"> | |
| <FileText className="h-5 w-5 mr-2 text-red-600" /> | |
| <span className="font-medium">PDF</span> | |
| </span> | |
| </label> | |
| </div> | |
| </div> | |
| <div> | |
| <div className="flex items-center justify-between mb-3"> | |
| <p className="font-medium">Columns to Include</p> | |
| <button | |
| type="button" | |
| onClick={() => toggleAllColumns(!Object.values(selectedColumns).every(v => v))} | |
| className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-700 py-1 px-2 rounded border flex items-center" | |
| > | |
| {Object.values(selectedColumns).every(v => v) ? ( | |
| <> | |
| <X className="h-3 w-3 mr-1" /> | |
| Uncheck All | |
| </> | |
| ) : ( | |
| <> | |
| <Check className="h-3 w-3 mr-1" /> | |
| Check All | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| <div className="bg-gray-50 rounded-md p-3 border"> | |
| <div className="space-y-3"> | |
| {/* Group 1: Identity */} | |
| <div className="bg-white rounded p-3 border shadow-sm"> | |
| <div className="text-sm font-medium text-gray-700 mb-2">Identity Information</div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> | |
| <label className="flex items-center space-x-2 cursor-pointer"> | |
| <div className="relative flex items-center justify-center h-5 w-5"> | |
| <input | |
| type="checkbox" | |
| checked={selectedColumns.empCode} | |
| onChange={(e) => setSelectedColumns({...selectedColumns, empCode: e.target.checked})} | |
| className="absolute opacity-0 h-0 w-0" | |
| id="col-empCode" | |
| /> | |
| <div className={`h-4 w-4 border rounded-sm ${selectedColumns.empCode ? 'bg-blue-600 border-blue-600' : 'bg-white border-gray-300'} flex items-center justify-center`}> | |
| {selectedColumns.empCode && <Check className="h-3 w-3 text-white" />} | |
| </div> | |
| </div> | |
| <span className="text-sm">Employee Code</span> | |
| </label> | |
| <label className="flex items-center space-x-2 cursor-pointer"> | |
| <div className="relative flex items-center justify-center h-5 w-5"> | |
| <input | |
| type="checkbox" | |
| checked={selectedColumns.name} | |
| onChange={(e) => setSelectedColumns({...selectedColumns, name: e.target.checked})} | |
| className="absolute opacity-0 h-0 w-0" | |
| id="col-name" | |
| /> | |
| <div className={`h-4 w-4 border rounded-sm ${selectedColumns.name ? 'bg-blue-600 border-blue-600' : 'bg-white border-gray-300'} flex items-center justify-center`}> | |
| {selectedColumns.name && <Check className="h-3 w-3 text-white" />} | |
| </div> | |
| </div> | |
| <span className="text-sm">Name</span> | |
| </label> | |
| </div> | |
| </div> | |
| {/* Group 2: Contact */} | |
| <div className="bg-white rounded p-3 border shadow-sm"> | |
| <div className="text-sm font-medium text-gray-700 mb-2">Contact Information</div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> | |
| <label className="flex items-center space-x-2 cursor-pointer"> | |
| <div className="relative flex items-center justify-center h-5 w-5"> | |
| <input | |
| type="checkbox" | |
| checked={selectedColumns.email} | |
| onChange={(e) => setSelectedColumns({...selectedColumns, email: e.target.checked})} | |
| className="absolute opacity-0 h-0 w-0" | |
| id="col-email" | |
| /> | |
| <div className={`h-4 w-4 border rounded-sm ${selectedColumns.email ? 'bg-blue-600 border-blue-600' : 'bg-white border-gray-300'} flex items-center justify-center`}> | |
| {selectedColumns.email && <Check className="h-3 w-3 text-white" />} | |
| </div> | |
| </div> | |
| <span className="text-sm">Email</span> | |
| </label> | |
| <label className="flex items-center space-x-2 cursor-pointer"> | |
| <div className="relative flex items-center justify-center h-5 w-5"> | |
| <input | |
| type="checkbox" | |
| checked={selectedColumns.mobile} | |
| onChange={(e) => setSelectedColumns({...selectedColumns, mobile: e.target.checked})} | |
| className="absolute opacity-0 h-0 w-0" | |
| id="col-mobile" | |
| /> | |
| <div className={`h-4 w-4 border rounded-sm ${selectedColumns.mobile ? 'bg-blue-600 border-blue-600' : 'bg-white border-gray-300'} flex items-center justify-center`}> | |
| {selectedColumns.mobile && <Check className="h-3 w-3 text-white" />} | |
| </div> | |
| </div> | |
| <span className="text-sm">Mobile</span> | |
| </label> | |
| </div> | |
| </div> | |
| {/* Group 3: Job */} | |
| <div className="bg-white rounded p-3 border shadow-sm"> | |
| <div className="text-sm font-medium text-gray-700 mb-2">Job Information</div> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-2"> | |
| <label className="flex items-center space-x-2 cursor-pointer"> | |
| <div className="relative flex items-center justify-center h-5 w-5"> | |
| <input | |
| type="checkbox" | |
| checked={selectedColumns.type} | |
| onChange={(e) => setSelectedColumns({...selectedColumns, type: e.target.checked})} | |
| className="absolute opacity-0 h-0 w-0" | |
| id="col-type" | |
| /> | |
| <div className={`h-4 w-4 border rounded-sm ${selectedColumns.type ? 'bg-blue-600 border-blue-600' : 'bg-white border-gray-300'} flex items-center justify-center`}> | |
| {selectedColumns.type && <Check className="h-3 w-3 text-white" />} | |
| </div> | |
| </div> | |
| <span className="text-sm">Type</span> | |
| </label> | |
| <label className="flex items-center space-x-2 cursor-pointer"> | |
| <div className="relative flex items-center justify-center h-5 w-5"> | |
| <input | |
| type="checkbox" | |
| checked={selectedColumns.role} | |
| onChange={(e) => setSelectedColumns({...selectedColumns, role: e.target.checked})} | |
| className="absolute opacity-0 h-0 w-0" | |
| id="col-role" | |
| /> | |
| <div className={`h-4 w-4 border rounded-sm ${selectedColumns.role ? 'bg-blue-600 border-blue-600' : 'bg-white border-gray-300'} flex items-center justify-center`}> | |
| {selectedColumns.role && <Check className="h-3 w-3 text-white" />} | |
| </div> | |
| </div> | |
| <span className="text-sm">Role</span> | |
| </label> | |
| <label className="flex items-center space-x-2 cursor-pointer"> | |
| <div className="relative flex items-center justify-center h-5 w-5"> | |
| <input | |
| type="checkbox" | |
| checked={selectedColumns.status} | |
| onChange={(e) => setSelectedColumns({...selectedColumns, status: e.target.checked})} | |
| className="absolute opacity-0 h-0 w-0" | |
| id="col-status" | |
| /> | |
| <div className={`h-4 w-4 border rounded-sm ${selectedColumns.status ? 'bg-blue-600 border-blue-600' : 'bg-white border-gray-300'} flex items-center justify-center`}> | |
| {selectedColumns.status && <Check className="h-3 w-3 text-white" />} | |
| </div> | |
| </div> | |
| <span className="text-sm">Status</span> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex flex-col sm:flex-row justify-end p-4 border-t gap-2"> | |
| <Button variant="outline" onClick={onClose} className="w-full sm:w-auto"> | |
| Cancel | |
| </Button> | |
| <Button onClick={handleExport} className="w-full sm:w-auto"> | |
| <Download className="mr-2 h-4 w-4" /> | |
| Export | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // Custom mobile actions dropdown component | |
| interface MobileActionsDropdownProps { | |
| employee: Employee; | |
| onView: (id: number) => void; | |
| onEdit: (id: number) => void; | |
| onDelete: (id: number) => void; | |
| } | |
| const MobileActionsDropdown = ({ employee, onView, onEdit, onDelete }: MobileActionsDropdownProps) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| // Close the dropdown when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| }, []); | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="sm:hidden" | |
| onClick={() => setIsOpen(!isOpen)} | |
| > | |
| <MoreHorizontal className="h-4 w-4" /> | |
| </Button> | |
| {isOpen && ( | |
| <div className="absolute z-50 top-full right-0 mt-1 w-48 bg-white rounded-md shadow-lg border py-1 animate-in fade-in-0 zoom-in-95"> | |
| <button | |
| type="button" | |
| className="flex items-center w-full text-left px-3 py-2 text-sm hover:bg-gray-100" | |
| onClick={() => { | |
| onView(employee.id); | |
| setIsOpen(false); | |
| }} | |
| > | |
| <Eye className="h-4 w-4 mr-2" /> | |
| View | |
| </button> | |
| <button | |
| type="button" | |
| className="flex items-center w-full text-left px-3 py-2 text-sm hover:bg-gray-100" | |
| onClick={() => { | |
| onEdit(employee.id); | |
| setIsOpen(false); | |
| }} | |
| > | |
| <Edit className="h-4 w-4 mr-2" /> | |
| Edit | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| // Custom filter dropdown component - reusable base | |
| interface FilterDropdownProps { | |
| isOpen: boolean; | |
| title: string; | |
| children: React.ReactNode; | |
| dropdownRef?: React.RefObject<HTMLDivElement>; | |
| } | |
| const FilterDropdown = ({ isOpen, title, children, dropdownRef }: FilterDropdownProps) => { | |
| if (!isOpen) return null; | |
| return ( | |
| <div ref={dropdownRef} className="absolute top-full left-0 mt-1 w-[240px] bg-white rounded-md shadow-lg border z-50 py-1 animate-in fade-in-0 zoom-in-95"> | |
| <div className="p-2 font-medium border-b mb-1">{title}</div> | |
| <div className="p-1 max-h-[300px] overflow-auto"> | |
| {children} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const Employees = () => { | |
| const navigate = useNavigate(); | |
| const location = useLocation(); | |
| const queryClient = useQueryClient(); | |
| const isMobile = useIsMobile(); | |
| const [isSidebarOpen, setIsSidebarOpen] = useState(!isMobile); | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [roleFilter, setRoleFilter] = useState<number | null>(null); | |
| const [statusFilter, setStatusFilter] = useState<string | null>(null); | |
| const [isExportModalOpen, setIsExportModalOpen] = useState(false); | |
| const [isRoleFilterOpen, setIsRoleFilterOpen] = useState(false); | |
| const [isStatusFilterOpen, setIsStatusFilterOpen] = useState(false); | |
| const [viewMode, setViewMode] = useState<'table' | 'cards'>('table'); | |
| // Status update popup state | |
| const [statusPopupOpen, setStatusPopupOpen] = useState<number | null>(null); | |
| const statusBadgeRefs = useRef<{[key: number]: HTMLDivElement | null}>({}); | |
| // References for filter buttons and popups | |
| const roleFilterButtonRef = useRef<HTMLButtonElement>(null); | |
| const statusFilterButtonRef = useRef<HTMLButtonElement>(null); | |
| const roleFilterDropdownRef = useRef<HTMLDivElement>(null); | |
| const statusFilterDropdownRef = useRef<HTMLDivElement>(null); | |
| // Status options for filter | |
| const statusOptions = ['Active', 'Inactive', 'On Leave', 'Terminated', 'Unknown']; | |
| // Auto-switch to cards view on mobile | |
| useEffect(() => { | |
| if (isMobile && viewMode === 'table') { | |
| setViewMode('cards'); | |
| } else if (!isMobile && viewMode === 'cards') { | |
| setViewMode('table'); | |
| } | |
| }, [isMobile, viewMode]); | |
| // Setup status update mutation | |
| const statusUpdateMutation = useMutation({ | |
| mutationFn: ({ id, status }: { id: number, status: string }) => { | |
| return employeeApi.updateStatus(id, status); | |
| }, | |
| onSuccess: (data) => { | |
| if (data.success) { | |
| toast.success(data.message || "Status updated successfully"); | |
| // Refetch employee data to reflect the changes | |
| refetch(); | |
| } else { | |
| toast.error(data.message || "Failed to update status"); | |
| } | |
| }, | |
| onError: (error) => { | |
| console.error("Status update error:", error); | |
| toast.error("An error occurred while updating the status"); | |
| } | |
| }); | |
| // Handle clicks outside the filter popups | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| // Close role filter if clicked outside both button and dropdown | |
| if ( | |
| isRoleFilterOpen && | |
| roleFilterButtonRef.current && | |
| !roleFilterButtonRef.current.contains(event.target as Node) && | |
| roleFilterDropdownRef.current && | |
| !roleFilterDropdownRef.current.contains(event.target as Node) | |
| ) { | |
| setIsRoleFilterOpen(false); | |
| } | |
| // Close status filter if clicked outside both button and dropdown | |
| if ( | |
| isStatusFilterOpen && | |
| statusFilterButtonRef.current && | |
| !statusFilterButtonRef.current.contains(event.target as Node) && | |
| statusFilterDropdownRef.current && | |
| !statusFilterDropdownRef.current.contains(event.target as Node) | |
| ) { | |
| setIsStatusFilterOpen(false); | |
| } | |
| }; | |
| // Add event listener | |
| document.addEventListener('mousedown', handleClickOutside); | |
| // Clean up | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| }, [isRoleFilterOpen, isStatusFilterOpen]); | |
| // Update sidebar state when screen size changes | |
| useEffect(() => { | |
| setIsSidebarOpen(!isMobile); | |
| }, [isMobile]); | |
| // Close sidebar on mobile when route changes | |
| useEffect(() => { | |
| if (isMobile) { | |
| setIsSidebarOpen(false); | |
| } | |
| }, [location, isMobile]); | |
| // Fetch employees | |
| const { data: employees, isLoading, error, refetch } = useQuery({ | |
| queryKey: ["employees"], | |
| queryFn: () => employeeApi.getAllForAdmin(), | |
| }); | |
| // Debug logging to see employee data structure | |
| useEffect(() => { | |
| if (employees && employees.length > 0) { | |
| console.log("Employee data structure:", employees[0]); | |
| console.log("All employees:", employees); | |
| // Ensure all employees have status values | |
| const hasStatusField = employees.some(emp => emp.status !== undefined); | |
| if (!hasStatusField) { | |
| console.log("Employees missing status field, normalizing data..."); | |
| // Add status field to all employees if missing | |
| const normalizedEmployees = employees.map(emp => ({ | |
| ...emp, | |
| status: emp.status || 'Unknown' | |
| })); | |
| // Update query cache with normalized employees | |
| queryClient.setQueryData(['employees'], normalizedEmployees); | |
| } | |
| } | |
| }, [employees, queryClient]); | |
| // Fetch roles | |
| const { data: rolesData, isLoading: rolesLoading } = useQuery({ | |
| queryKey: ["roles"], | |
| queryFn: roleService.getAll, | |
| }); | |
| // Normalize roles data to ensure consistent structure | |
| const roles = useMemo(() => { | |
| if (!rolesData) return null; | |
| return rolesData.map((role: any) => { | |
| // Ensure both id and roleId are available | |
| if (role.id === undefined && role.roleId !== undefined) { | |
| return { ...role, id: role.roleId }; | |
| } | |
| if (role.roleId === undefined && role.id !== undefined) { | |
| return { ...role, roleId: role.id }; | |
| } | |
| return role; | |
| }); | |
| }, [rolesData]); | |
| // Toggle sidebar | |
| const toggleSidebar = () => { | |
| setIsSidebarOpen(!isSidebarOpen); | |
| }; | |
| // Get role name from role ID using fetched roles | |
| const getRoleName = (roleId: number) => { | |
| if (!roles) return "Loading..."; | |
| // Find role by ID (should work with normalized data) | |
| const role = roles.find(r => r.roleId === roleId || r.id === roleId); | |
| if (role) { | |
| return role.roleName; | |
| } | |
| console.log(`Role with ID ${roleId} not found in`, roles); | |
| return `Role ${roleId}`; | |
| }; | |
| // Get role badge color based on role ID | |
| const getRoleBadgeVariant = (roleId: number) => { | |
| if (!roles) return "outline"; | |
| // Create a mapping of roleId to variants | |
| const roleVariants: Record<number, "default" | "secondary" | "outline" | "destructive"> = {}; | |
| // Assign variants based on role index | |
| roles.forEach((role: any, index: number) => { | |
| const variants: Array<"default" | "secondary" | "outline" | "destructive"> = ["default", "secondary", "outline"]; | |
| // Support both roleId and id property names | |
| const id = role.roleId !== undefined ? role.roleId : role.id; | |
| if (id !== undefined) { | |
| roleVariants[id] = variants[index % variants.length]; | |
| } | |
| }); | |
| return roleVariants[roleId] || "outline"; | |
| }; | |
| // Delete employee handler | |
| const handleDeleteEmployee = async (id: number) => { | |
| if (window.confirm("Are you sure you want to delete this employee?")) { | |
| try { | |
| // Actual API call would go here | |
| // await employeeService.delete(id); | |
| toast.success("Employee has been successfully deleted"); | |
| // Refresh the employee list | |
| refetch(); | |
| } catch (error) { | |
| toast.error("Failed to delete employee"); | |
| } | |
| } | |
| }; | |
| // Helper function to get role ID regardless of property name | |
| const getRoleId = (role: any): number => { | |
| return role.roleId !== undefined ? role.roleId : (role.id !== undefined ? role.id : 0); | |
| }; | |
| // Filter employees based on search query, role filter, and status filter | |
| const filteredEmployees = useMemo(() => { | |
| if (!employees) return []; | |
| console.log("Filtering employees: ", { | |
| total: employees.length, | |
| roleFilter, | |
| statusFilter, | |
| searchQuery | |
| }); | |
| return employees.filter((employee: Employee) => { | |
| // Apply role filter | |
| if (roleFilter !== null && employee.roleId !== roleFilter) { | |
| return false; | |
| } | |
| // Apply status filter with better handling of undefined values | |
| if (statusFilter !== null) { | |
| const employeeStatus = employee.status || 'Unknown'; | |
| console.log(`Employee ${employee.id} (${employee.firstName}): comparing status '${employeeStatus}' with filter '${statusFilter}'`); | |
| if (employeeStatus !== statusFilter) { | |
| return false; | |
| } | |
| } | |
| // Apply search filter | |
| if (!searchQuery) return true; | |
| const fullName = `${employee.firstName} ${employee.middleName || ''} ${employee.lastName}`.toLowerCase(); | |
| const query = searchQuery.toLowerCase(); | |
| return ( | |
| fullName.includes(query) || | |
| employee.email.toLowerCase().includes(query) || | |
| employee.empCode.toLowerCase().includes(query) || | |
| employee.mobile.includes(query) || | |
| employee.type.toLowerCase().includes(query) | |
| ); | |
| }); | |
| }, [employees, searchQuery, roleFilter, statusFilter]); | |
| // Get initials for avatar | |
| const getInitials = (firstName: string, lastName: string) => { | |
| return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); | |
| }; | |
| // Reset all filters | |
| const resetFilters = useCallback(() => { | |
| console.log("Resetting all filters"); | |
| setSearchQuery(""); | |
| setRoleFilter(null); | |
| setStatusFilter(null); | |
| setIsRoleFilterOpen(false); | |
| setIsStatusFilterOpen(false); | |
| }, []); | |
| // Add debug information for filtered results | |
| useEffect(() => { | |
| if (filteredEmployees && filteredEmployees.length > 0) { | |
| console.log(`Filter results: ${filteredEmployees.length} employees matched criteria`); | |
| console.log("First filtered employee:", filteredEmployees[0]); | |
| } else if (filteredEmployees && filteredEmployees.length === 0 && employees && employees.length > 0) { | |
| console.log("Filter returned zero results. Current filters:", { roleFilter, statusFilter, searchQuery }); | |
| } | |
| }, [filteredEmployees, employees, roleFilter, statusFilter, searchQuery]); | |
| // Set role filter with callback for reliability | |
| const updateRoleFilter = useCallback((roleId: number | null) => { | |
| console.log(`Setting role filter to: ${roleId === null ? 'null' : roleId}`); | |
| setRoleFilter(roleId); | |
| setIsRoleFilterOpen(false); | |
| }, []); | |
| // Set status filter with callback for reliability | |
| const updateStatusFilter = useCallback((status: string | null) => { | |
| console.log(`Setting status filter to: ${status === null ? 'null' : `"${status}"`}`); | |
| setStatusFilter(status); | |
| setIsStatusFilterOpen(false); | |
| }, []); | |
| // Handler for status update with confirmation | |
| const handleStatusUpdate = useCallback((employee: Employee, newStatus: string) => { | |
| // Skip if status is already the same | |
| if (employee.status === newStatus) return; | |
| // Ask for confirmation for Terminated status | |
| if (newStatus === 'Terminated') { | |
| if (window.confirm(`Are you sure you want to mark ${employee.firstName} ${employee.lastName} as Terminated? This action may affect their access to systems.`)) { | |
| statusUpdateMutation.mutate({ id: employee.id, status: newStatus }); | |
| } | |
| } else { | |
| // For other statuses, no confirmation needed | |
| statusUpdateMutation.mutate({ id: employee.id, status: newStatus }); | |
| } | |
| }, [statusUpdateMutation]); | |
| // Reporting update mutation | |
| const reportingUpdateMutation = useMutation({ | |
| mutationFn: ({ id, reportingToId }: { id: number, reportingToId: number | null }) => { | |
| return employeeApi.updateEmployeeReporting(id, reportingToId); | |
| }, | |
| onSuccess: (data) => { | |
| if (data.success) { | |
| toast.success(data.message || "Reporting updated successfully"); | |
| // Refetch employee data to reflect the changes | |
| refetch(); | |
| } else { | |
| toast.error(data.message || "Failed to update reporting"); | |
| } | |
| }, | |
| onError: (error) => { | |
| console.error("Reporting update error:", error); | |
| toast.error("An error occurred while updating the reporting"); | |
| } | |
| }); | |
| // Handler for reporting update | |
| const handleReportingUpdate = useCallback( | |
| (employeeId: number, reportingToId: number | null) => { | |
| reportingUpdateMutation.mutate({ id: employeeId, reportingToId }); | |
| }, | |
| [reportingUpdateMutation] | |
| ); | |
| return ( | |
| <div className="flex min-h-screen flex-col"> | |
| <Header toggleSidebar={toggleSidebar} isSidebarOpen={isSidebarOpen} /> | |
| <Sidebar isOpen={isSidebarOpen} setIsOpen={setIsSidebarOpen} /> | |
| <main className={`flex-1 p-3 pt-16 sm:p-6 sm:pt-20 md:p-10 md:pt-24 transition-all duration-300 ${isSidebarOpen && !isMobile ? "md:ml-64" : ""}`}> | |
| <div className="mx-auto max-w-7xl"> | |
| <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4 sm:mb-6 gap-4"> | |
| <div> | |
| <h2 className="text-2xl sm:text-3xl font-bold tracking-tight">Employees</h2> | |
| <p className="text-muted-foreground text-sm sm:text-base"> | |
| Manage your organization's employees | |
| </p> | |
| </div> | |
| <Button onClick={() => navigate("/employee-register")} className="w-full sm:w-auto"> | |
| <UserPlus className="mr-2 h-4 w-4" /> | |
| Add Employee | |
| </Button> | |
| </div> | |
| {/* Import/Export actions - Compact design */} | |
| <div className="mb-3"> | |
| <div className="flex items-center justify-between gap-2 px-1"> | |
| <div className="flex items-center gap-2 text-xs text-muted-foreground"> | |
| <FileText className="h-3.5 w-3.5" /> | |
| <span>{filteredEmployees?.length || 0} employees</span> | |
| </div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| <ImportEmployeesDialog | |
| onImportComplete={() => { | |
| refetch(); | |
| toast.success("Employee list refreshed"); | |
| }} | |
| /> | |
| <DownloadTemplateButton size="sm" variant="outline" /> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setIsExportModalOpen(true)} | |
| > | |
| <Download className="h-4 w-4 mr-2" /> | |
| <span className="hidden sm:inline">Export</span> | |
| <span className="sm:hidden">Export</span> | |
| </Button> | |
| <CustomExportModal | |
| isOpen={isExportModalOpen} | |
| onClose={() => setIsExportModalOpen(false)} | |
| employees={filteredEmployees || []} | |
| getRoleName={getRoleName} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| <Card className="h-[calc(100vh-280px)] flex flex-col"> | |
| <CardHeader className="pb-3 flex-shrink-0"> | |
| {/* Single Row Header with Title, Search, and Filters */} | |
| <div className="flex flex-col lg:flex-row items-start lg:items-center gap-3"> | |
| {/* Title Section */} | |
| <div className="flex-shrink-0"> | |
| <CardTitle className="text-lg">Employee Directory</CardTitle> | |
| <CardDescription className="text-xs"> | |
| {filteredEmployees?.length || 0} of {employees?.length || 0} | |
| </CardDescription> | |
| </div> | |
| {/* Search Bar */} | |
| <div className="relative flex-1 min-w-[200px]"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search employees..." | |
| className="pl-9 h-9" | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| /> | |
| </div> | |
| {/* Filters and View Toggle */} | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| {/* Role Filter */} | |
| <div className="relative"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className={`${roleFilter !== null ? 'border-primary bg-primary/5' : ''}`} | |
| onClick={() => { | |
| setIsRoleFilterOpen(!isRoleFilterOpen); | |
| setIsStatusFilterOpen(false); | |
| }} | |
| ref={roleFilterButtonRef} | |
| > | |
| <Filter className="mr-2 h-4 w-4" /> | |
| <span className="hidden sm:inline">Role Filter</span> | |
| <span className="sm:hidden">Role</span> | |
| {roleFilter !== null && ( | |
| <Badge variant="secondary" className="ml-2 px-1.5 py-0 text-xs"> | |
| <span className="hidden sm:inline">{getRoleName(roleFilter)}</span> | |
| <span className="sm:hidden">1</span> | |
| </Badge> | |
| )} | |
| </Button> | |
| {/* Custom Role Filter Popup */} | |
| {isRoleFilterOpen && ( | |
| <FilterDropdown | |
| isOpen={isRoleFilterOpen} | |
| title="Filter by Role" | |
| dropdownRef={roleFilterDropdownRef} | |
| children={ | |
| <> | |
| <button | |
| type="button" | |
| className={`w-full text-left px-3 py-2 rounded-sm text-sm hover:bg-gray-100 ${roleFilter === null ? 'bg-primary/10 font-medium' : ''}`} | |
| onClick={() => updateRoleFilter(null)} | |
| > | |
| <div className="flex items-center"> | |
| {roleFilter === null && <Check className="h-4 w-4 mr-2" />} | |
| <span>All Roles</span> | |
| </div> | |
| </button> | |
| {!rolesLoading && roles?.map((role: any) => ( | |
| <button | |
| key={getRoleId(role)} | |
| type="button" | |
| className={`w-full text-left px-3 py-2 rounded-sm text-sm hover:bg-gray-100 ${roleFilter === getRoleId(role) ? 'bg-primary/10 font-medium' : ''}`} | |
| onClick={() => updateRoleFilter(getRoleId(role))} | |
| > | |
| <div className="flex items-center"> | |
| {roleFilter === getRoleId(role) && <Check className="h-4 w-4 mr-2" />} | |
| <span>{role.roleName}</span> | |
| </div> | |
| </button> | |
| ))} | |
| </> | |
| } | |
| /> | |
| )} | |
| </div> | |
| {/* Status Filter */} | |
| <div className="relative"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className={`${statusFilter !== null ? 'border-primary bg-primary/5' : ''}`} | |
| onClick={() => { | |
| setIsStatusFilterOpen(!isStatusFilterOpen); | |
| setIsRoleFilterOpen(false); | |
| }} | |
| ref={statusFilterButtonRef} | |
| > | |
| <Filter className="mr-2 h-4 w-4" /> | |
| <span className="hidden sm:inline">Status Filter</span> | |
| <span className="sm:hidden">Status</span> | |
| {statusFilter !== null && ( | |
| <Badge variant="secondary" className="ml-2 px-1.5 py-0 text-xs"> | |
| <span className="hidden sm:inline">{statusFilter}</span> | |
| <span className="sm:hidden">1</span> | |
| </Badge> | |
| )} | |
| </Button> | |
| {/* Custom Status Filter Popup */} | |
| {isStatusFilterOpen && ( | |
| <FilterDropdown | |
| isOpen={isStatusFilterOpen} | |
| title="Filter by Status" | |
| dropdownRef={statusFilterDropdownRef} | |
| children={ | |
| <> | |
| <button | |
| type="button" | |
| className={`w-full text-left px-3 py-2 rounded-sm text-sm hover:bg-gray-100 ${statusFilter === null ? 'bg-primary/10 font-medium' : ''}`} | |
| onClick={() => updateStatusFilter(null)} | |
| > | |
| <div className="flex items-center"> | |
| {statusFilter === null && <Check className="h-4 w-4 mr-2" />} | |
| <span>All Statuses</span> | |
| </div> | |
| </button> | |
| {statusOptions.map((status) => ( | |
| <button | |
| key={status} | |
| type="button" | |
| className={`w-full text-left px-3 py-2 rounded-sm text-sm hover:bg-gray-100 ${statusFilter === status ? 'bg-primary/10 font-medium' : ''}`} | |
| onClick={() => updateStatusFilter(status)} | |
| > | |
| <div className="flex items-center"> | |
| {statusFilter === status && <Check className="h-4 w-4 mr-2" />} | |
| <span>{status}</span> | |
| </div> | |
| </button> | |
| ))} | |
| </> | |
| } | |
| /> | |
| )} | |
| </div> | |
| {/* Reset Button */} | |
| {(searchQuery || roleFilter !== null || statusFilter !== null) && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={resetFilters} | |
| className="flex-shrink-0" | |
| title="Reset all filters" | |
| > | |
| <X className="h-4 w-4 mr-1" /> | |
| <span className="hidden sm:inline">Reset</span> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="p-3 sm:p-6 pt-0 flex-1 overflow-x-hidden overflow-y-auto"> | |
| {isLoading || rolesLoading ? ( | |
| <div className="flex justify-center items-center h-64"> | |
| <Loader2 className="h-8 w-8 animate-spin text-primary" /> | |
| </div> | |
| ) : error ? ( | |
| <div className="flex justify-center items-center h-64 text-destructive"> | |
| <p>Error loading employees. Please try again later.</p> | |
| </div> | |
| ) : filteredEmployees?.length === 0 ? ( | |
| <div className="flex flex-col justify-center items-center h-64 text-muted-foreground"> | |
| <p>No employees found matching your filters.</p> | |
| {(searchQuery || roleFilter !== null) && ( | |
| <Button | |
| variant="link" | |
| onClick={resetFilters} | |
| className="mt-2" | |
| > | |
| Reset Filters | |
| </Button> | |
| )} | |
| </div> | |
| ) : ( | |
| <> | |
| {/* Mobile/Cards View */} | |
| {(isMobile || viewMode === 'cards') && ( | |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> | |
| {filteredEmployees?.map((employee: Employee) => ( | |
| <MobileEmployeeCard | |
| key={employee.id} | |
| employee={employee} | |
| getRoleName={getRoleName} | |
| getRoleBadgeVariant={getRoleBadgeVariant} | |
| onView={(id) => navigate(`/employee/${id}`)} | |
| onEdit={(id) => navigate(`/employee-edit/${id}`)} | |
| onDelete={(id) => handleDeleteEmployee(id)} | |
| onStatusChange={handleStatusUpdate} | |
| onReportingChange={handleReportingUpdate} | |
| isUpdating={statusUpdateMutation.isPending && statusUpdateMutation.variables?.id === employee.id} | |
| getInitials={getInitials} | |
| employees={employees || []} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| {/* Desktop Table View */} | |
| {!isMobile && viewMode === 'table' && ( | |
| <div className="rounded-md border overflow-x-auto overflow-y-visible"> | |
| <Table className="min-w-[800px]"> | |
| <TableHeader className="sticky top-0 bg-background z-10"> | |
| <TableRow> | |
| <TableHead>Employee</TableHead> | |
| <TableHead>Contact</TableHead> | |
| <TableHead>Role</TableHead> | |
| <TableHead>Status</TableHead> | |
| <TableHead>Reporting To</TableHead> | |
| <TableHead className="text-right sticky right-0 bg-background">Actions</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {filteredEmployees?.map((employee: Employee) => ( | |
| <TableRow key={employee.id} className="group"> | |
| <TableCell> | |
| <div className="flex items-center gap-3"> | |
| <Avatar className="h-9 w-9"> | |
| <AvatarImage src={null} alt={`${employee.firstName} ${employee.lastName}`} /> | |
| <AvatarFallback className="bg-primary/10 text-primary"> | |
| {getInitials(employee.firstName, employee.lastName)} | |
| </AvatarFallback> | |
| </Avatar> | |
| <div> | |
| <div className="font-medium"> | |
| {employee.firstName} {employee.lastName} | |
| </div> | |
| <div className="text-sm text-muted-foreground"> | |
| {employee.empCode} | |
| </div> | |
| </div> | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| <div> | |
| <div className="text-sm">{employee.email}</div> | |
| <div className="text-sm text-muted-foreground">{employee.mobile}</div> | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| <Badge variant={getRoleBadgeVariant(employee.roleId)}> | |
| {getRoleName(employee.roleId)} | |
| </Badge> | |
| </TableCell> | |
| <TableCell> | |
| <StatusDropdown | |
| employee={employee} | |
| onStatusChange={handleStatusUpdate} | |
| isUpdating={statusUpdateMutation.isPending && statusUpdateMutation.variables?.id === employee.id} | |
| /> | |
| </TableCell> | |
| <TableCell> | |
| <ReportingDropdown | |
| employee={employee} | |
| employees={employees || []} | |
| onReportingChange={handleReportingUpdate} | |
| isUpdating={reportingUpdateMutation.isPending && reportingUpdateMutation.variables?.id === employee.id} | |
| /> | |
| </TableCell> | |
| <TableCell className="text-right sticky right-0 bg-background"> | |
| <div className="flex justify-end gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => navigate(`/employee/${employee.id}`)} | |
| className="hidden sm:flex items-center group-hover:flex" | |
| > | |
| <Eye className="h-4 w-4 mr-1" /> | |
| View | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => navigate(`/employee-edit/${employee.id}`)} | |
| className="hidden sm:flex items-center group-hover:flex" | |
| > | |
| <Edit className="h-4 w-4 mr-1" /> | |
| Edit | |
| </Button> | |
| <MobileActionsDropdown | |
| employee={employee} | |
| onView={(id) => navigate(`/employee/${id}`)} | |
| onEdit={(id) => navigate(`/employee-edit/${id}`)} | |
| onDelete={(id) => handleDeleteEmployee(id)} | |
| /> | |
| </div> | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {/* Mobile Add Employee FAB */} | |
| <div className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 lg:hidden z-10"> | |
| <Button | |
| size="lg" | |
| className="h-12 w-12 sm:h-14 sm:w-14 rounded-full shadow-lg" | |
| onClick={() => navigate("/employee-register")} | |
| > | |
| <UserPlus className="h-5 w-5 sm:h-6 sm:w-6" /> | |
| <span className="sr-only">Add Employee</span> | |
| </Button> | |
| </div> | |
| </div> | |
| </main> | |
| {/* Overlay for mobile sidebar */} | |
| {isSidebarOpen && isMobile && ( | |
| <div | |
| className="fixed inset-0 bg-black/50 z-30" | |
| onClick={() => setIsSidebarOpen(false)} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default Employees; |