Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from "react"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| import { format, getYear, parseISO, differenceInCalendarDays, getMonth } from "date-fns"; | |
| import * as XLSX from 'xlsx'; | |
| import { | |
| Calendar as CalendarIcon, | |
| CheckCircle, | |
| XCircle, | |
| Loader2, | |
| Filter, | |
| SearchIcon, | |
| X, | |
| Download, | |
| Users, | |
| UserPlus, | |
| FileText, | |
| BarChart, | |
| ChevronDown, | |
| Check, | |
| Clock, | |
| User, | |
| AlertCircle, | |
| File, | |
| ChevronLeft, | |
| ChevronRight, | |
| ListFilter, | |
| SlidersHorizontal, | |
| Table as TableIcon, | |
| UserIcon, | |
| Info, | |
| FileSpreadsheet, | |
| } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from "@/components/ui/popover"; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
| import { Separator } from "@/components/ui/separator"; | |
| import { toast } from "sonner"; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCaption, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow | |
| } from "@/components/ui/table"; | |
| import { | |
| Leave, | |
| LeaveStatus, | |
| LeaveType, | |
| LeaveStatusUpdateRequest, | |
| Employee, | |
| LeaveMaster, | |
| LeaveBalance | |
| } from "@/types"; | |
| import { leaveApi } from "@/services/leaveApi"; | |
| import { employeeApi } from "@/services/employeeApi"; | |
| import { leaveMasterApi } from "@/services/leaveMasterApi"; | |
| import { leaveBalanceApi } from "@/services/leaveBalanceApi"; | |
| import LeaveCard from "@/components/leaves/LeaveCard"; | |
| import Header from "@/components/layout/header"; | |
| import Sidebar from "@/components/layout/sidebar"; | |
| import { useIsMobile } from "@/hooks/use-mobile"; | |
| import { Label } from "@/components/ui/label"; | |
| import { cn } from "@/lib/utils"; | |
| import CustomDialog from "@/components/CustomDialog"; | |
| import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { useForm } from "react-hook-form"; | |
| import { z } from "zod"; | |
| import { zodResolver } from "@hookform/resolvers/zod"; | |
| import { useToast } from "@/components/ui/use-toast"; | |
| import Layout from "@/components/layout/layout"; | |
| import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; | |
| import LeaveList from "@/components/leaves/LeaveList"; | |
| import { Calendar as CalendarComponent } from "@/components/ui/calendar"; | |
| import MonthlyReportTab from "./leaves-admin-monthly-report-tab"; | |
| import CalendarTab from "./leaves-admin-calendar-tab"; | |
| // Custom Dropdown Component | |
| interface CustomDropdownProps { | |
| value: string; | |
| onChange: (value: string) => void; | |
| options: { value: string; label: string; color?: string }[]; | |
| placeholder: string; | |
| showBadge?: boolean; | |
| disabled?: boolean; | |
| searchable?: boolean; | |
| } | |
| function CustomDropdown({ value, onChange, options, placeholder, showBadge = false, disabled = false, searchable = false }: CustomDropdownProps) { | |
| const [open, setOpen] = useState(false); | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const getLabel = () => { | |
| return options.find(option => option.value === value)?.label || placeholder; | |
| }; | |
| const getColor = () => { | |
| return options.find(option => option.value === value)?.color || ""; | |
| }; | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (!disabled) { | |
| setOpen(!open); | |
| } | |
| }; | |
| const handleOptionClick = (optionValue: string) => { | |
| onChange(optionValue); | |
| setOpen(false); | |
| setSearchQuery(""); | |
| }; | |
| const filteredOptions = searchable && searchQuery | |
| ? options.filter(option => | |
| option.label.toLowerCase().includes(searchQuery.toLowerCase()) | |
| ) | |
| : options; | |
| return ( | |
| <div className="relative w-full"> | |
| <div | |
| className={cn( | |
| "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer", | |
| open && "ring-2 ring-ring ring-offset-2", | |
| disabled && "opacity-50 cursor-not-allowed" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| {showBadge && value ? ( | |
| <div className="flex items-center gap-2"> | |
| <Badge className={getColor()}> | |
| {getLabel()} | |
| </Badge> | |
| </div> | |
| ) : ( | |
| <span className={!value ? "text-muted-foreground" : ""}>{getLabel()}</span> | |
| )} | |
| <ChevronDown className="h-4 w-4 shrink-0 opacity-50" /> | |
| </div> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover text-popover-foreground shadow-md"> | |
| {searchable && ( | |
| <div className="sticky top-0 bg-popover p-2 border-b"> | |
| <div className="relative"> | |
| <SearchIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| type="text" | |
| placeholder="Search..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="pl-8 h-9" | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| <div className="p-1"> | |
| {filteredOptions.length > 0 ? ( | |
| filteredOptions.map((option) => ( | |
| <div | |
| key={option.value} | |
| className={cn( | |
| "flex cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 hover:bg-accent hover:text-accent-foreground", | |
| value === option.value && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={() => handleOptionClick(option.value)} | |
| > | |
| {showBadge ? ( | |
| <Badge className={option.color}>{option.label}</Badge> | |
| ) : ( | |
| option.label | |
| )} | |
| {value === option.value && <Check className="h-4 w-4" />} | |
| </div> | |
| )) | |
| ) : ( | |
| <div className="px-2 py-6 text-center text-sm text-muted-foreground"> | |
| No results found | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Type for leave with employee info | |
| interface LeaveWithEmployee extends Leave { | |
| employeeName?: string; | |
| } | |
| // Type for monthly leave report | |
| interface EmployeeLeaveReport { | |
| employeeId: number; | |
| employeeName: string; | |
| employeeCode: string; | |
| halfDay: { count: number; dates: number[] }; | |
| annualLeave: { count: number; dates: number[] }; | |
| compensatoryOff: { count: number; dates: number[] }; | |
| workFromHome: { count: number; dates: number[] }; | |
| sickLeave: { count: number; dates: number[] }; | |
| casualLeave: { count: number; dates: number[] }; | |
| other: { count: number; dates: number[] }; | |
| } | |
| // Define the form schema for status update | |
| const statusUpdateSchema = z.object({ | |
| comments: z.string().optional(), | |
| }); | |
| type StatusUpdateFormValues = z.infer<typeof statusUpdateSchema>; | |
| // Custom LeaveDetailDialog component | |
| function CustomLeaveDetailDialog({ | |
| leave, | |
| employee, | |
| approver, | |
| isOpen, | |
| onClose, | |
| onDownloadDocument, | |
| leaveMasters, | |
| getLeaveTypeName, | |
| }: { | |
| leave: Leave | null; | |
| employee: Employee | null; | |
| approver: Employee | null; | |
| isOpen: boolean; | |
| onClose: () => void; | |
| onDownloadDocument?: (leave: Leave) => void; | |
| leaveMasters: LeaveMaster[]; | |
| getLeaveTypeName: (id?: number) => string; | |
| }) { | |
| if (!leave) return null; | |
| const [leaveBalances, setLeaveBalances] = useState<LeaveBalance[]>([]); | |
| const [loadingBalances, setLoadingBalances] = useState(false); | |
| useEffect(() => { | |
| const fetchBalances = async () => { | |
| if (leave && isOpen && leave.employeeId) { | |
| setLoadingBalances(true); | |
| try { | |
| const year = getYear(new Date(leave.startDate)); | |
| const balances = await leaveBalanceApi.getByEmployeeIdAndYear(leave.employeeId, year); | |
| setLeaveBalances(balances); | |
| } catch (error) { | |
| console.error("Failed to fetch leave balances", error); | |
| } finally { | |
| setLoadingBalances(false); | |
| } | |
| } | |
| }; | |
| fetchBalances(); | |
| }, [leave, isOpen]); | |
| const startDate = new Date(leave.startDate); | |
| const endDate = new Date(leave.endDate); | |
| const durationDays = differenceInCalendarDays(endDate, startDate) + 1; | |
| // Find the associated leave master if available | |
| const leaveMaster = leave.leaveMasterId ? leaveMasters.find(m => m.id === leave.leaveMasterId) : null; | |
| // Helper function to get status icon | |
| const getStatusIcon = (status: LeaveStatus) => { | |
| switch (status) { | |
| case "Approved": | |
| return <CheckCircle className="h-4 w-4" />; | |
| case "Rejected": | |
| return <XCircle className="h-4 w-4" />; | |
| case "Cancelled": | |
| return <X className="h-4 w-4" />; | |
| case "Pending": | |
| default: | |
| return <AlertCircle className="h-4 w-4" />; | |
| } | |
| }; | |
| // Helper function to get badge variant | |
| const getStatusBadgeVariant = (status: LeaveStatus) => { | |
| switch (status) { | |
| case "Approved": | |
| return "bg-green-500 hover:bg-green-600"; | |
| case "Rejected": | |
| return "bg-red-500 hover:bg-red-600"; | |
| case "Cancelled": | |
| return "bg-gray-500 hover:bg-gray-600"; | |
| case "Pending": | |
| default: | |
| return "bg-yellow-500 hover:bg-yellow-600"; | |
| } | |
| }; | |
| return ( | |
| <CustomDialog | |
| isOpen={isOpen} | |
| onClose={onClose} | |
| title={`${leave.leaveMasterId ? getLeaveTypeName(leave.leaveMasterId) : leave.type} Leave Request`} | |
| description="Leave request details and status" | |
| > | |
| <div className="flex items-center justify-between mb-4"> | |
| <Badge | |
| className={cn("flex items-center gap-1", getStatusBadgeVariant(leave.status))} | |
| > | |
| {getStatusIcon(leave.status)} | |
| <span>{leave.status}</span> | |
| </Badge> | |
| </div> | |
| <div className="space-y-4"> | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Employee:</div> | |
| <div className="col-span-3 flex items-center gap-2"> | |
| <User className="h-4 w-4" /> | |
| <span> | |
| {employee ? `${employee.firstName} ${employee.lastName}` : `ID: ${leave.employeeId}`} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Period:</div> | |
| <div className="col-span-3 flex items-center gap-2"> | |
| <CalendarIcon className="h-4 w-4" /> | |
| <span> | |
| {format(startDate, "PPP")} to {format(endDate, "PPP")} | |
| {" "}({durationDays} {durationDays === 1 ? "day" : "days"}) | |
| </span> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Type:</div> | |
| <div className="col-span-3 flex items-center gap-2"> | |
| <FileText className="h-4 w-4" /> | |
| <span>{leave.leaveMasterId ? getLeaveTypeName(leave.leaveMasterId) : leave.type}</span> | |
| </div> | |
| </div> | |
| {leaveMaster && ( | |
| <div className="grid grid-cols-4 items-start gap-4"> | |
| <div className="font-medium">Leave Policy:</div> | |
| <div className="col-span-3 flex flex-col gap-1"> | |
| {leaveMaster.description && ( | |
| <div className="text-sm">{leaveMaster.description}</div> | |
| )} | |
| {leaveMaster.requiresDocumentation && ( | |
| <div className="text-sm text-amber-600 flex items-center gap-1"> | |
| <FileText className="h-3 w-3" /> | |
| <span>Documentation required</span> | |
| </div> | |
| )} | |
| {leaveMaster.minDaysBeforeApply > 0 && ( | |
| <div className="text-sm text-blue-600 flex items-center gap-1"> | |
| <CalendarIcon className="h-3 w-3" /> | |
| <span>Must apply {leaveMaster.minDaysBeforeApply} days in advance</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| <div className="grid grid-cols-4 items-start gap-4"> | |
| <div className="font-medium">Reason:</div> | |
| <div className="col-span-3 flex items-start gap-2"> | |
| <FileText className="h-4 w-4 mt-0.5" /> | |
| <span>{leave.reason}</span> | |
| </div> | |
| </div> | |
| {leave.appliedOn && ( | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Applied On:</div> | |
| <div className="col-span-3 flex items-center gap-2"> | |
| <Clock className="h-4 w-4" /> | |
| <span>{format(new Date(leave.appliedOn), "PPP")}</span> | |
| </div> | |
| </div> | |
| )} | |
| {leave.halfDay && ( | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Half Day:</div> | |
| <div className="col-span-3">Yes</div> | |
| </div> | |
| )} | |
| {leave.documents && ( | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Documents:</div> | |
| <div className="col-span-3"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => onDownloadDocument?.(leave)} | |
| > | |
| <Download className="h-4 w-4 mr-2" /> | |
| View Documents | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {(leave.status === "Approved" || leave.status === "Rejected") && ( | |
| <> | |
| {approver && ( | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Actioned By:</div> | |
| <div className="col-span-3 flex items-center gap-2"> | |
| <User className="h-4 w-4" /> | |
| <span> | |
| {approver.firstName} {approver.lastName} | |
| </span> | |
| </div> | |
| </div> | |
| )} | |
| {leave.actionDate && ( | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Action Date:</div> | |
| <div className="col-span-3 flex items-center gap-2"> | |
| <Clock className="h-4 w-4" /> | |
| <span>{format(new Date(leave.actionDate), "PPP")}</span> | |
| </div> | |
| </div> | |
| )} | |
| {leave.comments && ( | |
| <div className="grid grid-cols-4 items-start gap-4"> | |
| <div className="font-medium">Comments:</div> | |
| <div className="col-span-3 flex items-start gap-2"> | |
| <FileText className="h-4 w-4 mt-0.5" /> | |
| <span>{leave.comments}</span> | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| <div className="pt-4"> | |
| <h4 className="text-sm font-medium mb-2">Leave Balances ({leave ? getYear(new Date(leave.startDate)) : ''})</h4> | |
| {loadingBalances ? ( | |
| <div className="flex justify-center p-4"> | |
| <Loader2 className="animate-spin h-5 w-5 text-muted-foreground" /> | |
| </div> | |
| ) : leaveBalances.length > 0 ? ( | |
| <div className="border rounded-md"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead className="h-8 text-xs">Type</TableHead> | |
| <TableHead className="h-8 text-right text-xs">Allocated</TableHead> | |
| <TableHead className="h-8 text-right text-xs">Used</TableHead> | |
| <TableHead className="h-8 text-right text-xs">Balance</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {leaveBalances.map((balance) => ( | |
| <TableRow key={balance.id} className="h-8"> | |
| <TableCell className="py-1 text-xs font-medium">{balance.leaveType}</TableCell> | |
| <TableCell className="text-right py-1 text-xs">{balance.allocated}</TableCell> | |
| <TableCell className="text-right py-1 text-xs">{balance.used}</TableCell> | |
| <TableCell className="text-right py-1 text-xs font-bold text-primary">{balance.pending}</TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| ) : ( | |
| <p className="text-xs text-muted-foreground italic">No leave balance data available.</p> | |
| )} | |
| </div> | |
| <div className="mt-6 flex justify-end"> | |
| <Button variant="outline" onClick={onClose}> | |
| Close | |
| </Button> | |
| </div> | |
| </CustomDialog> | |
| ); | |
| } | |
| function CustomLeaveStatusUpdateDialog({ | |
| leave, | |
| action, | |
| isOpen, | |
| onClose, | |
| onSubmit, | |
| employeeName, | |
| approverId, | |
| isSubmitting, | |
| leaveMasters, | |
| getLeaveTypeName, | |
| }: { | |
| leave: Leave | null; | |
| action: "approve" | "reject"; | |
| isOpen: boolean; | |
| onClose: () => void; | |
| onSubmit: (data: LeaveStatusUpdateRequest) => Promise<void>; | |
| employeeName?: string; | |
| approverId: number; | |
| isSubmitting: boolean; | |
| leaveMasters: LeaveMaster[]; | |
| getLeaveTypeName: (id?: number) => string; | |
| }) { | |
| const form = useForm<StatusUpdateFormValues>({ | |
| resolver: zodResolver(statusUpdateSchema), | |
| defaultValues: { | |
| comments: "", | |
| }, | |
| }); | |
| if (!leave) return null; | |
| const handleSubmit = async (values: StatusUpdateFormValues) => { | |
| try { | |
| const updateData: LeaveStatusUpdateRequest = { | |
| id: leave.id, | |
| status: action === "approve" ? "Approved" : "Rejected", | |
| approvedById: approverId, | |
| actionDate: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss"), | |
| comments: values.comments, | |
| }; | |
| await onSubmit(updateData); | |
| form.reset(); | |
| onClose(); | |
| } catch (error) { | |
| console.error(`Error ${action} leave request:`, error); | |
| toast.error(`Failed to ${action} leave request`); | |
| } | |
| }; | |
| // Only treat as Comp Off Request if compOff flag is explicitly true | |
| // If leave type is Compensatory but compOff is false, it's a regular leave request | |
| const isCompOff = leave.compOff === true; | |
| const requestType = isCompOff ? "Comp Off Request" : "Leave Request"; | |
| return ( | |
| <CustomDialog | |
| isOpen={isOpen} | |
| onClose={onClose} | |
| title={`${action === "approve" ? "Approve" : "Reject"} ${requestType}`} | |
| description={ | |
| action === "approve" | |
| ? `Approve the ${isCompOff ? "compensatory off" : "leave"} request and provide any optional comments.` | |
| : `Reject the ${isCompOff ? "compensatory off" : "leave"} request and provide a reason for rejection.` | |
| } | |
| allowClose={!isSubmitting} | |
| isPending={isSubmitting} | |
| > | |
| <div className="space-y-4"> | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Employee:</div> | |
| <div className="col-span-3"> | |
| {employeeName ? employeeName : `ID: ${leave.employeeId}`} | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Date:</div> | |
| <div className="col-span-3"> | |
| {format(new Date(leave.startDate), "PPP")} to {format(new Date(leave.endDate), "PPP")} | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Type:</div> | |
| <div className="col-span-3">{leave.leaveMasterId ? getLeaveTypeName(leave.leaveMasterId) : leave.type}</div> | |
| </div> | |
| <div className="grid grid-cols-4 items-center gap-4"> | |
| <div className="font-medium">Reason:</div> | |
| <div className="col-span-3">{leave.reason}</div> | |
| </div> | |
| <Form {...form}> | |
| <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 mt-6"> | |
| <FormField | |
| control={form.control} | |
| name="comments" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Comments</FormLabel> | |
| <FormControl> | |
| <Textarea | |
| placeholder={action === "approve" ? "Add optional comments" : "Reason for rejection"} | |
| {...field} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| {action === "approve" && ( | |
| <div className="mt-2"> | |
| <p className="text-xs text-muted-foreground mb-1">Quick suggestions:</p> | |
| <div className="flex flex-wrap gap-1"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| size="sm" | |
| className="h-6 px-2 text-xs" | |
| onClick={() => form.setValue('comments', 'Approved. Enjoy your leave!')} | |
| > | |
| Approved. Enjoy your leave! | |
| </Button> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| size="sm" | |
| className="h-6 px-2 text-xs" | |
| onClick={() => form.setValue('comments', 'Leave approved. Have a relaxing time off!')} | |
| > | |
| Leave approved. Have a relaxing time off! | |
| </Button> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| size="sm" | |
| className="h-6 px-2 text-xs" | |
| onClick={() => form.setValue('comments', 'Approved. Please ensure handoff of responsibilities.')} | |
| > | |
| Approved. Please ensure handoff of responsibilities. | |
| </Button> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| size="sm" | |
| className="h-6 px-2 text-xs" | |
| onClick={() => form.setValue('comments', 'Leave request approved.')} | |
| > | |
| Leave request approved. | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </FormItem> | |
| )} | |
| /> | |
| <div className="flex justify-end gap-2 mt-4"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| onClick={onClose} | |
| disabled={isSubmitting} | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| type="submit" | |
| variant={action === "approve" ? "default" : "destructive"} | |
| disabled={isSubmitting} | |
| > | |
| {isSubmitting ? ( | |
| <> | |
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| {action === "approve" ? "Approving..." : "Rejecting..."} | |
| </> | |
| ) : ( | |
| <> | |
| {action === "approve" ? ( | |
| <CheckCircle className="mr-2 h-4 w-4" /> | |
| ) : ( | |
| <XCircle className="mr-2 h-4 w-4" /> | |
| )} | |
| {action === "approve" ? "Approve" : "Reject"} | |
| </> | |
| )} | |
| </Button> | |
| </div> | |
| </form> | |
| </Form> | |
| </div> | |
| </CustomDialog> | |
| ); | |
| } | |
| const LeavesAdminPage: React.FC = () => { | |
| const { userData } = useAuth(); | |
| const [leaves, setLeaves] = useState<LeaveWithEmployee[]>([]); | |
| const [filteredLeaves, setFilteredLeaves] = useState<LeaveWithEmployee[]>([]); | |
| const [employees, setEmployees] = useState<Employee[]>([]); | |
| const [teamMembers, setTeamMembers] = useState<Employee[]>([]); | |
| const [leaveMasters, setLeaveMasters] = useState<LeaveMaster[]>([]); | |
| const [stats, setStats] = useState({ | |
| total: 0, | |
| pending: 0, | |
| approved: 0, | |
| rejected: 0, | |
| cancelled: 0, | |
| }); | |
| // Dialog states | |
| const [selectedLeave, setSelectedLeave] = useState<Leave | null>(null); | |
| const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null); | |
| const [showLeaveDetailDialog, setShowLeaveDetailDialog] = useState(false); | |
| const [showApproveDialog, setShowApproveDialog] = useState(false); | |
| const [showRejectDialog, setShowRejectDialog] = useState(false); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| // Filter states | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| const [statusFilter, setStatusFilter] = useState<LeaveStatus | "All">("Pending"); | |
| const [typeFilter, setTypeFilter] = useState<LeaveType | "All">("All"); | |
| const [employeeFilter, setEmployeeFilter] = useState<number | "All">("All"); | |
| const [yearFilter, setYearFilter] = useState<number>(getYear(new Date())); | |
| const [viewMode, setViewMode] = useState<"card" | "table">("card"); | |
| const [apiError, setApiError] = useState<string | null>(null); | |
| // Monthly report states | |
| const currentDate = new Date(); | |
| const [selectedMonth, setSelectedMonth] = useState<number>(getMonth(currentDate)); | |
| const [selectedReportYear, setSelectedReportYear] = useState<number>(getYear(currentDate)); | |
| const [reportData, setReportData] = useState<EmployeeLeaveReport[]>([]); | |
| const [isExporting, setIsExporting] = useState(false); | |
| const months = [ | |
| "January", "February", "March", "April", "May", "June", | |
| "July", "August", "September", "October", "November", "December" | |
| ]; | |
| // Date range filter state | |
| const [dateRange, setDateRange] = useState<{ from: Date | null; to: Date | null }>({ from: null, to: null }); | |
| const [showFromCalendar, setShowFromCalendar] = useState(false); | |
| const [showToCalendar, setShowToCalendar] = useState(false); | |
| const fromDateRef = useRef<HTMLDivElement>(null); | |
| const toDateRef = useRef<HTMLDivElement>(null); | |
| // Handle click outside to close calendars | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (fromDateRef.current && !fromDateRef.current.contains(event.target as Node)) { | |
| setShowFromCalendar(false); | |
| } | |
| if (toDateRef.current && !toDateRef.current.contains(event.target as Node)) { | |
| setShowToCalendar(false); | |
| } | |
| }; | |
| document.addEventListener("mousedown", handleClickOutside); | |
| return () => { | |
| document.removeEventListener("mousedown", handleClickOutside); | |
| }; | |
| }, []); | |
| // Get array of years for filtering (current year and 2 years back) | |
| const currentYear = getYear(new Date()); | |
| const years = [currentYear, currentYear - 1, currentYear - 2]; | |
| const [leaveMastersLoading, setLeaveMastersLoading] = useState(false); | |
| console.log(userData?.roleName); | |
| const isAdmin = userData?.roleName === 'Admin' || userData?.roleName === 'CEO' || userData?.roleName === 'COO' || userData?.roleName === 'Senior Project Manager' || userData?.roleName === 'HR' || userData?.roleName === 'Sr. HR Manager'; | |
| const isProjectManager = userData?.roleName === 'Project Manager'; | |
| // Fetch leave masters (leave types) | |
| useEffect(() => { | |
| const fetchLeaveMasters = async () => { | |
| try { | |
| const masters = await leaveMasterApi.getAll(); | |
| setLeaveMasters(masters); | |
| } catch (error) { | |
| console.error("Error fetching leave masters:", error); | |
| } | |
| }; | |
| fetchLeaveMasters(); | |
| }, []); | |
| // Fetch all employees | |
| useEffect(() => { | |
| const fetchEmployees = async () => { | |
| try { | |
| setIsLoading(true); | |
| let employeeData: Employee[] = []; | |
| if (isAdmin) { | |
| // Admin can see all employees | |
| employeeData = await employeeApi.getAll(); | |
| } else if (isProjectManager && userData?.employeeId) { | |
| // Project Manager can only see team members | |
| employeeData = await employeeApi.getByManagerId(userData.employeeId); | |
| } else if (userData?.employeeId) { | |
| // Other roles can only see themselves | |
| const response = await employeeApi.getById(userData.employeeId); | |
| if (response.success && response.data) { | |
| employeeData = [response.data]; | |
| } | |
| } | |
| setEmployees(employeeData); | |
| setTeamMembers(employeeData); | |
| } catch (error) { | |
| console.error("Error fetching employees:", error); | |
| toast.error("Failed to fetch employee data"); | |
| } | |
| }; | |
| fetchEmployees(); | |
| }, [userData, isAdmin, toast]); | |
| // Fetch leaves based on the employees | |
| useEffect(() => { | |
| const fetchLeaves = async () => { | |
| try { | |
| setApiError(null); | |
| setIsLoading(true); | |
| if (employees.length === 0) { | |
| console.log('No employees to fetch leaves for'); | |
| setIsLoading(false); | |
| return; | |
| } | |
| // Get all leave records | |
| let allLeaves: Leave[] = []; | |
| if (isAdmin) { | |
| // Admin can see all leaves | |
| console.log('Fetching all leave records for admin'); | |
| allLeaves = await leaveApi.getAll(); | |
| } else { | |
| // For project managers or other roles, only fetch leaves for the employees they have access to | |
| console.log(`Fetching leaves for ${employees.length} team members`); | |
| // Create an array of promises to fetch leaves for each employee | |
| const promises = employees.map(emp => { | |
| console.log(`Fetching leaves for employee ID: ${emp.id}`); | |
| return leaveApi.getByEmployeeId(emp.id); | |
| }); | |
| try { | |
| const results = await Promise.all(promises); | |
| // Combine all leaves into one array | |
| allLeaves = results.flat(); | |
| console.log(`Successfully fetched ${allLeaves.length} leaves for team members`); | |
| } catch (error) { | |
| console.error("Error fetching leaves for team members:", error); | |
| toast.error("Error fetching some team member leave records"); | |
| } | |
| } | |
| if (allLeaves.length === 0) { | |
| console.warn('No leaves fetched - either no leaves exist or API connection issue'); | |
| } | |
| // Enrich leaves with employee names | |
| const leavesWithEmployee = allLeaves.map((leave) => { | |
| let employeeName = "Unknown"; | |
| const employee = employees.find(emp => emp.id === leave.employeeId); | |
| if (employee) { | |
| // Format name properly with a single space | |
| employeeName = `${employee.firstName || ""} ${employee.lastName || ""}`.trim(); | |
| } | |
| return { | |
| ...leave, | |
| employeeName | |
| }; | |
| }); | |
| setLeaves(leavesWithEmployee); | |
| // Calculate stats | |
| const total = allLeaves.length; | |
| const pending = allLeaves.filter(l => l.status === "Pending").length; | |
| const approved = allLeaves.filter(l => l.status === "Approved").length; | |
| const rejected = allLeaves.filter(l => l.status === "Rejected").length; | |
| const cancelled = allLeaves.filter(l => l.status === "Cancelled").length; | |
| setStats({ | |
| total, | |
| pending, | |
| approved, | |
| rejected, | |
| cancelled | |
| }); | |
| } catch (error) { | |
| console.error("Error fetching leaves:", error); | |
| setApiError("Failed to connect to leaves API. Please check network connection and API availability."); | |
| toast.error("Failed to fetch leave records"); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| if (employees.length > 0) { | |
| fetchLeaves(); | |
| } else if (!isLoading && employees.length === 0) { | |
| setIsLoading(false); | |
| } | |
| }, [employees, isAdmin, toast]); | |
| // Get leave type name by ID | |
| const getLeaveTypeName = (id?: number): string => { | |
| if (!id) return "General"; | |
| const found = leaveMasters.find(m => m.id === id); | |
| return found ? found.leaveType : "Unknown"; | |
| }; | |
| // Create a mapping to get leave master ID from type string | |
| const getLeaveTypeId = (type: string): number | undefined => { | |
| const master = leaveMasters.find(m => | |
| m.leaveType.toLowerCase() === type.toLowerCase() | |
| ); | |
| return master?.id; | |
| }; | |
| // Helper function to get color based on leave type | |
| const getLeaveTypeColor = (type: string): string => { | |
| switch (type.toLowerCase()) { | |
| case "annual": | |
| return "bg-blue-500 hover:bg-blue-600"; | |
| case "sick": | |
| return "bg-red-500 hover:bg-red-600"; | |
| case "personal": | |
| return "bg-green-500 hover:bg-green-600"; | |
| case "maternity": | |
| return "bg-purple-500 hover:bg-purple-600"; | |
| case "paternity": | |
| return "bg-indigo-500 hover:bg-indigo-600"; | |
| case "unpaid": | |
| return "bg-gray-500 hover:bg-gray-600"; | |
| case "compensatory": | |
| return "bg-orange-500 hover:bg-orange-600"; | |
| case "bereavement": | |
| return "bg-slate-500 hover:bg-slate-600"; | |
| case "half day": | |
| return "bg-amber-500 hover:bg-amber-600"; | |
| case "casual": | |
| return "bg-emerald-500 hover:bg-emerald-600"; | |
| default: | |
| return "bg-neutral-500 hover:bg-neutral-600"; | |
| } | |
| }; | |
| // Apply filters whenever filter states change | |
| useEffect(() => { | |
| if (!leaves.length) return; | |
| let filtered = [...leaves]; | |
| // Filter by year | |
| filtered = filtered.filter(leave => { | |
| const leaveYear = getYear(new Date(leave.startDate)); | |
| return leaveYear === yearFilter; | |
| }); | |
| // Filter by status | |
| if (statusFilter !== "All") { | |
| filtered = filtered.filter(leave => leave.status === statusFilter); | |
| } | |
| // Filter by type | |
| if (typeFilter !== "All") { | |
| filtered = filtered.filter(leave => { | |
| // Check both type and leaveMasterId | |
| if (leave.leaveMasterId) { | |
| const master = leaveMasters.find(m => m.id === leave.leaveMasterId); | |
| return master?.leaveType === typeFilter; | |
| } else { | |
| return leave.type === typeFilter; | |
| } | |
| }); | |
| } | |
| // Filter by employee | |
| if (employeeFilter !== "All") { | |
| filtered = filtered.filter(leave => leave.employeeId === employeeFilter); | |
| } | |
| // Filter by search term (reason or employee name) | |
| if (searchTerm) { | |
| filtered = filtered.filter(leave => | |
| leave.reason.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| leave.employeeName?.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ); | |
| } | |
| // Apply date range filter | |
| if (dateRange.from && dateRange.to) { | |
| filtered = filtered.filter(leave => { | |
| const leaveStartDate = parseISO(leave.startDate); | |
| const leaveEndDate = parseISO(leave.endDate); | |
| const fromDate = dateRange.from!; | |
| const toDate = dateRange.to!; | |
| // Set the time of toDate to the end of the day for inclusivity | |
| const toDateEndOfDay = new Date(toDate); | |
| toDateEndOfDay.setHours(23, 59, 59, 999); | |
| // Check if the leave overlaps with the selected range | |
| return ( | |
| (leaveStartDate <= toDateEndOfDay && leaveEndDate >= fromDate) // Leave overlaps with the range | |
| ); | |
| }); | |
| } else if (dateRange.from) { | |
| filtered = filtered.filter(leave => { | |
| const leaveStartDate = parseISO(leave.startDate); | |
| return leaveStartDate >= dateRange.from!; | |
| }); | |
| } else if (dateRange.to) { | |
| filtered = filtered.filter(leave => { | |
| const leaveEndDate = parseISO(leave.endDate); | |
| const toDate = dateRange.to!; | |
| // Set the time of toDate to the end of the day for inclusivity | |
| const toDateEndOfDay = new Date(toDate); | |
| toDateEndOfDay.setHours(23, 59, 59, 999); | |
| return leaveEndDate <= toDateEndOfDay; | |
| }); | |
| } | |
| setFilteredLeaves(filtered); | |
| }, [leaves, searchTerm, statusFilter, typeFilter, employeeFilter, yearFilter, dateRange]); | |
| const handleViewLeaveDetails = (leave: Leave) => { | |
| setSelectedLeave(leave); | |
| setSelectedEmployee(employees.find(emp => emp.id === leave.employeeId) || null); | |
| setShowLeaveDetailDialog(true); | |
| }; | |
| const handleApproveLeave = (leave: Leave) => { | |
| setSelectedLeave(leave); | |
| setShowApproveDialog(true); | |
| }; | |
| const handleRejectLeave = (leave: Leave) => { | |
| setSelectedLeave(leave); | |
| setShowRejectDialog(true); | |
| }; | |
| const handleCancelLeave = async (leave: Leave) => { | |
| if (!userData?.employeeId || !leave) return; | |
| if (window.confirm("Are you sure you want to cancel this approved leave?")) { | |
| try { | |
| const updateData: LeaveStatusUpdateRequest = { | |
| id: leave.id, | |
| status: "Cancelled", | |
| approvedById: userData.employeeId, | |
| actionDate: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss"), | |
| comments: "Cancelled by admin", | |
| }; | |
| await leaveApi.updateStatus(updateData); | |
| // Refresh leaves list | |
| setLeaves(prev => prev.map(l => l.id === leave.id ? { ...l, status: "Cancelled" } : l)); | |
| // Update stats | |
| setStats(prev => ({ | |
| ...prev, | |
| approved: prev.approved - (leave.status === "Approved" ? 1 : 0), | |
| pending: prev.pending - (leave.status === "Pending" ? 1 : 0), | |
| cancelled: prev.cancelled + 1 | |
| })); | |
| toast.success("Leave request cancelled successfully"); | |
| } catch (error) { | |
| console.error("Error cancelling leave request:", error); | |
| toast.error("Failed to cancel leave request"); | |
| } | |
| } | |
| }; | |
| const handleStatusUpdate = async (data: LeaveStatusUpdateRequest) => { | |
| setIsSubmitting(true); | |
| try { | |
| await leaveApi.updateStatus(data); | |
| setLeaves((prev) => { | |
| const updated = prev.map((leave) => { | |
| if (leave.id !== data.id) return leave; | |
| return { | |
| ...leave, | |
| status: data.status, | |
| approvedById: data.approvedById, | |
| actionDate: data.actionDate, | |
| comments: data.comments, | |
| }; | |
| }); | |
| // Update stats based on the updated list | |
| const total = updated.length; | |
| const pending = updated.filter(l => l.status === "Pending").length; | |
| const approved = updated.filter(l => l.status === "Approved").length; | |
| const rejected = updated.filter(l => l.status === "Rejected").length; | |
| const cancelled = updated.filter(l => l.status === "Cancelled").length; | |
| setStats({ total, pending, approved, rejected, cancelled }); | |
| return updated; | |
| }); | |
| // Close dialogs | |
| setShowApproveDialog(false); | |
| setShowRejectDialog(false); | |
| setSelectedLeave(null); | |
| // Show success message | |
| toast.success(`Leave request ${data.status.toLowerCase()}`); | |
| } catch (error) { | |
| console.error("Error updating leave status:", error); | |
| toast.error("Failed to update leave status"); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| const resetFilters = () => { | |
| setSearchTerm(""); | |
| setStatusFilter("Pending"); | |
| setTypeFilter("All"); | |
| setEmployeeFilter("All"); | |
| setYearFilter(currentYear); | |
| setDateRange({ from: null, to: null }); | |
| }; | |
| const exportToCSV = () => { | |
| if (!filteredLeaves.length) return; | |
| // Create CSV content | |
| const headers = ['ID', 'Employee', 'Type', 'Start Date', 'End Date', 'Status', 'Applied On', 'Reason']; | |
| const rows = filteredLeaves.map(leave => [ | |
| leave.id, | |
| leave.employeeName || `Employee ID: ${leave.employeeId}`, | |
| leave.type, | |
| format(new Date(leave.startDate), 'yyyy-MM-dd'), | |
| format(new Date(leave.endDate), 'yyyy-MM-dd'), | |
| leave.status, | |
| leave.appliedOn ? format(new Date(leave.appliedOn), 'yyyy-MM-dd') : '', | |
| leave.reason.replace(/,/g, ';') // Replace commas to avoid CSV issues | |
| ]); | |
| const csvContent = [ | |
| headers.join(','), | |
| ...rows.map(row => row.join(',')) | |
| ].join('\n'); | |
| // Create and download file | |
| const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.setAttribute('href', url); | |
| link.setAttribute('download', `leaves-report-${format(new Date(), 'yyyy-MM-dd')}.csv`); | |
| link.style.visibility = 'hidden'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }; | |
| const selectedLeaveEmployeeName = selectedLeave && employees.find(e => e.id === selectedLeave.employeeId) | |
| ? `${employees.find(e => e.id === selectedLeave.employeeId)?.firstName || ""} ${employees.find(e => e.id === selectedLeave.employeeId)?.lastName || ""}`.trim() | |
| : undefined; | |
| return ( | |
| <Layout> | |
| <div className="space-y-6"> | |
| {/* Page title */} | |
| <div className="flex flex-col gap-2 mb-6"> | |
| <h1 className="text-3xl font-bold tracking-tight"> | |
| {isAdmin ? "Leave Management" : "Team Leave Requests"} | |
| </h1> | |
| <p className="text-muted-foreground"> | |
| {isAdmin | |
| ? "View and manage leave requests from all employees" | |
| : isProjectManager | |
| ? "View and manage leave requests from your team members" | |
| : "View and manage your leave requests" | |
| } | |
| </p> | |
| <div className="text-sm text-muted-foreground flex items-center gap-1"> | |
| <UserIcon className="h-3.5 w-3.5" /> | |
| {isAdmin ? ( | |
| <span>Admin access to all leave requests</span> | |
| ) : isProjectManager ? ( | |
| <span>Viewing team members' leave requests</span> | |
| ) : ( | |
| <span>Restricted view</span> | |
| )} | |
| </div> | |
| </div> | |
| {isProjectManager && ( | |
| <Alert className="mb-6 border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20"> | |
| <Info className="h-4 w-4 text-blue-500 dark:text-blue-400" /> | |
| <AlertTitle>Team View</AlertTitle> | |
| <AlertDescription> | |
| You are viewing leave requests for your team members only. | |
| {employees.length === 0 ? ( | |
| <span className="text-yellow-600 dark:text-yellow-400 font-medium block mt-1"> | |
| No team members found. Please contact an administrator if this is incorrect. | |
| </span> | |
| ) : ( | |
| <span className="text-blue-600 dark:text-blue-400 font-medium block mt-1"> | |
| Managing {employees.length} team member{employees.length !== 1 ? 's' : ''}. | |
| </span> | |
| )} | |
| </AlertDescription> | |
| </Alert> | |
| )} | |
| <Tabs defaultValue="all" className="space-y-4"> | |
| <div className="flex flex-col-reverse sm:flex-row items-start sm:items-center justify-between gap-4"> | |
| <TabsList> | |
| <TabsTrigger value="all">All Leaves</TabsTrigger> | |
| <TabsTrigger value="calendar">Calendar</TabsTrigger> | |
| <TabsTrigger value="monthly-report">Monthly Report</TabsTrigger> | |
| </TabsList> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setViewMode(viewMode === "table" ? "card" : "table")} | |
| > | |
| {viewMode === "table" ? "Card View" : "Table View"} | |
| </Button> | |
| <Button variant="outline" size="sm" onClick={exportToCSV}> | |
| <Download className="mr-2 h-4 w-4" /> | |
| Export | |
| </Button> | |
| </div> | |
| </div> | |
| <TabsContent value="all" className="space-y-4"> | |
| <div className="grid gap-4 md:grid-cols-4"> | |
| <Card className="md:col-span-4"> | |
| <CardHeader className="pb-3"> | |
| <CardTitle>Leave Requests</CardTitle> | |
| <CardDescription> | |
| Manage employee leave requests | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| {/* Filter section */} | |
| <div className="bg-slate-50 dark:bg-slate-900/50 p-3 rounded-md border border-slate-100 dark:border-slate-800 mb-2"> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <Filter className="h-4 w-4 text-slate-500" /> | |
| <h3 className="text-sm font-semibold">Filters</h3> | |
| {(searchTerm || statusFilter !== "Pending" || typeFilter !== "All" || employeeFilter !== "All" || yearFilter !== currentYear || dateRange.from || dateRange.to) && ( | |
| <Button variant="ghost" size="sm" onClick={resetFilters} className="ml-auto h-8 py-0 px-2 text-xs text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"> | |
| <X className="h-3 w-3 mr-1" /> | |
| Reset All | |
| </Button> | |
| )} | |
| </div> | |
| <div className="flex flex-wrap gap-3 items-end"> | |
| <div className="grid gap-1.5 w-full sm:w-auto sm:flex-1"> | |
| <Label htmlFor="search" className="text-xs">Search</Label> | |
| <div className="relative"> | |
| <SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| id="search" | |
| type="search" | |
| placeholder="Search by reason or employee..." | |
| className="pl-8 h-9" | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| /> | |
| </div> | |
| </div> | |
| <div className="grid gap-1.5 w-full sm:w-[120px]"> | |
| <Label className="text-xs">Status</Label> | |
| <CustomDropdown | |
| value={statusFilter as string} | |
| onChange={(value) => setStatusFilter(value as LeaveStatus | "All")} | |
| options={[ | |
| { value: "All", label: "All Statuses" }, | |
| { value: "Pending", label: "Pending", color: "bg-yellow-500 hover:bg-yellow-600" }, | |
| { value: "Approved", label: "Approved", color: "bg-green-500 hover:bg-green-600" }, | |
| { value: "Rejected", label: "Rejected", color: "bg-red-500 hover:bg-red-600" }, | |
| { value: "Cancelled", label: "Cancelled", color: "bg-gray-500 hover:bg-gray-600" } | |
| ]} | |
| placeholder="Status" | |
| showBadge={true} | |
| /> | |
| </div> | |
| <div className="grid gap-1.5 w-full sm:w-[120px]"> | |
| <Label className="text-xs">Type</Label> | |
| <CustomDropdown | |
| value={typeFilter as string} | |
| onChange={(value) => setTypeFilter(value as LeaveType | "All")} | |
| options={ | |
| leaveMastersLoading | |
| ? [{ value: "All", label: "Loading..." }] | |
| : [ | |
| { value: "All", label: "All Types" }, | |
| ...leaveMasters.map(master => ({ | |
| value: master.leaveType, | |
| label: master.leaveType, | |
| color: getLeaveTypeColor(master.leaveType) | |
| })) | |
| ] | |
| } | |
| placeholder="Type" | |
| showBadge={true} | |
| disabled={leaveMastersLoading} | |
| /> | |
| </div> | |
| <div className="grid gap-1.5 w-full sm:w-auto sm:flex-1"> | |
| <Label className="text-xs">Employee</Label> | |
| <CustomDropdown | |
| value={employeeFilter.toString()} | |
| onChange={(value) => setEmployeeFilter(value === "All" ? "All" : parseInt(value))} | |
| options={[ | |
| { value: "All", label: "All Employees" }, | |
| ...employees.map(employee => ({ | |
| value: employee.id.toString(), | |
| label: `${employee.firstName} ${employee.lastName}` | |
| })) | |
| ]} | |
| placeholder="Filter by employee" | |
| searchable={true} | |
| /> | |
| </div> | |
| <div className="grid gap-1.5 w-[120px]"> | |
| <Label className="text-xs">Year</Label> | |
| <CustomDropdown | |
| value={yearFilter.toString()} | |
| onChange={(value) => setYearFilter(parseInt(value))} | |
| options={years.map(year => ({ | |
| value: year.toString(), | |
| label: year.toString() | |
| }))} | |
| placeholder="Year" | |
| /> | |
| </div> | |
| <div className="flex flex-col space-y-2 w-full md:w-auto md:min-w-[240px]"> | |
| <label className="text-sm font-medium text-gray-700">Date Range</label> | |
| <div className="flex items-center space-x-2"> | |
| <div ref={fromDateRef} className="relative"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="w-full md:w-auto text-sm h-9 justify-start text-left font-normal" | |
| onClick={() => { | |
| setShowToCalendar(false); | |
| setShowFromCalendar(!showFromCalendar); | |
| }} | |
| > | |
| <CalendarIcon className="mr-1 h-3.5 w-3.5" /> | |
| {dateRange.from ? format(dateRange.from, "PPP") : "From"} | |
| </Button> | |
| {showFromCalendar && ( | |
| <div | |
| className="absolute top-full left-0 mt-2 bg-background border rounded-lg shadow-md p-3 z-[100]" | |
| style={{ minWidth: '320px' }} | |
| > | |
| <CalendarComponent | |
| mode="single" | |
| selected={dateRange.from} | |
| onSelect={(date) => { | |
| setDateRange(prev => ({ ...prev, from: date })); | |
| setShowFromCalendar(false); | |
| }} | |
| initialFocus | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| <span className="text-sm text-gray-500">to</span> | |
| <div ref={toDateRef} className="relative"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="w-full md:w-auto text-sm h-9 justify-start text-left font-normal" | |
| onClick={() => { | |
| setShowFromCalendar(false); | |
| setShowToCalendar(!showToCalendar); | |
| }} | |
| > | |
| <CalendarIcon className="mr-1 h-3.5 w-3.5" /> | |
| {dateRange.to ? format(dateRange.to, "PPP") : "To"} | |
| </Button> | |
| {showToCalendar && ( | |
| <div | |
| className="absolute top-full left-0 mt-2 bg-background border rounded-lg shadow-md p-3 z-[100]" | |
| style={{ minWidth: '320px' }} | |
| > | |
| <CalendarComponent | |
| mode="single" | |
| selected={dateRange.to} | |
| onSelect={(date) => { | |
| setDateRange(prev => ({ ...prev, to: date })); | |
| setShowToCalendar(false); | |
| }} | |
| disabled={(date) => { | |
| if (dateRange.from) { | |
| const normalizedFromDate = new Date(dateRange.from); | |
| normalizedFromDate.setHours(0, 0, 0, 0); | |
| const normalizedDate = new Date(date); | |
| normalizedDate.setHours(0, 0, 0, 0); | |
| return normalizedDate < normalizedFromDate; | |
| } | |
| return false; | |
| }} | |
| initialFocus | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| {(dateRange.from || dateRange.to) && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => setDateRange({ from: null, to: null })} | |
| className="p-1 h-9" | |
| > | |
| <X className="h-4 w-4" /> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="rounded-md border overflow-hidden"> | |
| {renderLeavesList(filteredLeaves)} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </TabsContent> | |
| <TabsContent value="calendar" className="space-y-4"> | |
| <CalendarTab | |
| leaves={leaves} | |
| employees={employees} | |
| isLoading={isLoading} | |
| /> | |
| </TabsContent> | |
| <TabsContent value="monthly-report" className="space-y-4"> | |
| <MonthlyReportTab | |
| leaves={leaves} | |
| employees={employees} | |
| isLoading={isLoading} | |
| /> | |
| </TabsContent> | |
| </Tabs> | |
| </div> | |
| {/* Dialogs */} | |
| <CustomLeaveDetailDialog | |
| leave={selectedLeave} | |
| employee={selectedEmployee} | |
| approver={selectedLeave?.approvedById ? employees.find(e => e.id === selectedLeave.approvedById) || null : null} | |
| isOpen={showLeaveDetailDialog} | |
| onClose={() => setShowLeaveDetailDialog(false)} | |
| onDownloadDocument={leave => { | |
| if (leave.documents) { | |
| window.open(leave.documents, '_blank'); | |
| } | |
| }} | |
| leaveMasters={leaveMasters} | |
| getLeaveTypeName={getLeaveTypeName} | |
| /> | |
| <CustomLeaveStatusUpdateDialog | |
| leave={selectedLeave} | |
| action="approve" | |
| isOpen={showApproveDialog} | |
| onClose={() => setShowApproveDialog(false)} | |
| onSubmit={handleStatusUpdate} | |
| employeeName={selectedLeaveEmployeeName} | |
| approverId={userData?.employeeId || 0} | |
| isSubmitting={isSubmitting} | |
| leaveMasters={leaveMasters} | |
| getLeaveTypeName={getLeaveTypeName} | |
| /> | |
| <CustomLeaveStatusUpdateDialog | |
| leave={selectedLeave} | |
| action="reject" | |
| isOpen={showRejectDialog} | |
| onClose={() => setShowRejectDialog(false)} | |
| onSubmit={handleStatusUpdate} | |
| employeeName={selectedLeaveEmployeeName} | |
| approverId={userData?.employeeId || 0} | |
| isSubmitting={isSubmitting} | |
| leaveMasters={leaveMasters} | |
| getLeaveTypeName={getLeaveTypeName} | |
| /> | |
| </Layout> | |
| ); | |
| function renderLeavesList(leavesToRender: LeaveWithEmployee[]) { | |
| if (isLoading) { | |
| return ( | |
| <div className="flex justify-center items-center py-8"> | |
| <Loader2 className="h-8 w-8 animate-spin opacity-70" /> | |
| </div> | |
| ); | |
| } | |
| if (apiError) { | |
| return ( | |
| <div className="text-center py-8 border rounded-lg bg-destructive/10"> | |
| <XCircle className="h-8 w-8 mx-auto opacity-50 mb-2 text-destructive" /> | |
| <h3 className="font-medium text-lg">API Connection Error</h3> | |
| <p className="text-muted-foreground mb-4"> | |
| {apiError} | |
| </p> | |
| <Button | |
| variant="outline" | |
| onClick={() => window.location.reload()} | |
| > | |
| Retry Connection | |
| </Button> | |
| </div> | |
| ); | |
| } | |
| if (leavesToRender.length === 0) { | |
| return ( | |
| <div className="text-center py-8 border rounded-lg bg-muted/20"> | |
| <CalendarIcon className="h-8 w-8 mx-auto opacity-50 mb-2" /> | |
| <h3 className="font-medium text-lg">No leaves found</h3> | |
| <p className="text-muted-foreground"> | |
| Try adjusting your filters | |
| </p> | |
| </div> | |
| ); | |
| } | |
| if (viewMode === "card") { | |
| return ( | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> | |
| {leavesToRender.map(leave => ( | |
| <LeaveCard | |
| key={leave.id} | |
| leave={leave} | |
| employeeName={leave.employeeName} | |
| leaveType={leave.leaveMasterId ? getLeaveTypeName(leave.leaveMasterId) : leave.type} | |
| isAdmin={true} | |
| onViewDetails={handleViewLeaveDetails} | |
| onApprove={leave.status === "Pending" ? handleApproveLeave : undefined} | |
| onReject={leave.status === "Pending" ? handleRejectLeave : undefined} | |
| onCancel={leave.status === "Approved" ? handleCancelLeave : undefined} | |
| /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <Table> | |
| <TableCaption>List of leave requests</TableCaption> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead>Employee</TableHead> | |
| <TableHead>Type</TableHead> | |
| <TableHead>Duration</TableHead> | |
| <TableHead>Status</TableHead> | |
| <TableHead>Applied On</TableHead> | |
| <TableHead className="text-right">Actions</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {leavesToRender.map(leave => ( | |
| <TableRow key={leave.id}> | |
| <TableCell className="font-medium"> | |
| {leave.employeeName || `Employee ID: ${leave.employeeId}`} | |
| </TableCell> | |
| <TableCell> | |
| {leave.leaveMasterId ? getLeaveTypeName(leave.leaveMasterId) : leave.type} | |
| </TableCell> | |
| <TableCell> | |
| {format(new Date(leave.startDate), "MMM d, yyyy")} - | |
| {format(new Date(leave.endDate), "MMM d, yyyy")} | |
| </TableCell> | |
| <TableCell> | |
| <Badge variant={ | |
| leave.status === "Approved" ? "default" : | |
| leave.status === "Rejected" ? "destructive" : | |
| leave.status === "Cancelled" ? "outline" : "secondary" | |
| }> | |
| {leave.status} | |
| </Badge> | |
| </TableCell> | |
| <TableCell> | |
| {leave.appliedOn ? format(new Date(leave.appliedOn), "MMM d, yyyy") : "N/A"} | |
| </TableCell> | |
| <TableCell> | |
| <div className="flex justify-end items-center gap-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => handleViewLeaveDetails(leave)} | |
| > | |
| View | |
| </Button> | |
| {leave.status === "Pending" && ( | |
| <> | |
| <Button | |
| variant="default" | |
| size="sm" | |
| onClick={() => handleApproveLeave(leave)} | |
| > | |
| <CheckCircle className="h-4 w-4 mr-1" /> | |
| Approve | |
| </Button> | |
| <Button | |
| variant="destructive" | |
| size="sm" | |
| onClick={() => handleRejectLeave(leave)} | |
| > | |
| <XCircle className="h-4 w-4 mr-1" /> | |
| Reject | |
| </Button> | |
| </> | |
| )} | |
| {leave.status === "Approved" && ( | |
| <Button | |
| variant="destructive" | |
| size="sm" | |
| onClick={() => handleCancelLeave(leave)} | |
| > | |
| <XCircle className="h-4 w-4 mr-1" /> | |
| Cancel Approved | |
| </Button> | |
| )} | |
| </div> | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| ); | |
| } | |
| }; | |
| export default LeavesAdminPage; |