Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from "react"; | |
| import { useNavigate, useLocation } from "react-router-dom"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| import { format, getYear, parseISO, differenceInCalendarDays } from "date-fns"; | |
| import { | |
| CalendarPlus, | |
| Loader2, | |
| Calendar as CalendarIcon, | |
| Filter, | |
| SearchIcon, | |
| X, | |
| CheckCircle, | |
| XCircle, | |
| AlertCircle, | |
| FileText, | |
| Clock, | |
| Download, | |
| User, | |
| BarChart3, | |
| TrendingUp, | |
| ShieldAlert, | |
| Briefcase, | |
| Home, | |
| Info, | |
| RefreshCw, | |
| Gift, | |
| CalendarDays, | |
| ChevronDown | |
| } 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 { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogTrigger | |
| } from "@/components/ui/dialog"; | |
| import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Separator } from "@/components/ui/separator"; | |
| import { toast } from "sonner"; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
| import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Leave, LeaveBalance as LeaveBalanceType, LeaveCreateRequest, LeaveStatus, LeaveType, Holiday } from "@/types"; | |
| import { leaveApi } from "@/services/leaveApi"; | |
| import { employeeApi } from "@/services/employeeApi"; | |
| import LeaveForm from "@/components/leaves/LeaveForm"; | |
| import LeaveCard from "@/components/leaves/LeaveCard"; | |
| import CustomLeaveDetailDialog from "@/components/leaves/CustomLeaveDetailDialog"; | |
| import LeaveBalanceComponent from "@/components/leaves/LeaveBalance"; | |
| 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 CustomDialog from "@/components/CustomDialog"; | |
| import { Progress } from "@/components/ui/progress"; | |
| import { cn } from "@/lib/utils"; | |
| import { holidayApi } from "@/services/holidayApi"; | |
| import { leaveMasterApi } from "@/services/leaveMasterApi"; | |
| import { LeaveMaster } from "@/types"; | |
| // Custom Status Filter component | |
| function CustomStatusFilter({ value, onValueChange }) { | |
| const [open, setOpen] = useState(false); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const statusOptions = [ | |
| { value: "All", label: "All Statuses" }, | |
| { value: "Pending", label: "Pending" }, | |
| { value: "Approved", label: "Approved" }, | |
| { value: "Rejected", label: "Rejected" }, | |
| { value: "Cancelled", label: "Cancelled" } | |
| ]; | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => document.removeEventListener('mousedown', handleOutsideClick); | |
| }, [open]); | |
| const getStatusColor = (status: string) => { | |
| switch (status) { | |
| case "Pending": return "text-amber-500"; | |
| case "Approved": return "text-green-500"; | |
| case "Rejected": return "text-red-500"; | |
| case "Cancelled": return "text-gray-500"; | |
| default: return ""; | |
| } | |
| }; | |
| const selectedOption = statusOptions.find(option => option.value === value) || statusOptions[0]; | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <Button | |
| variant="outline" | |
| className="w-full h-10 justify-between bg-white dark:bg-gray-950" | |
| onClick={() => setOpen(!open)} | |
| > | |
| <div className="flex items-center"> | |
| <span className={cn(getStatusColor(value))}>{selectedOption.label}</span> | |
| </div> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </Button> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 w-full rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| {statusOptions.map((option) => ( | |
| <Button | |
| key={option.value} | |
| variant="ghost" | |
| className={cn( | |
| "w-full justify-start rounded-sm font-normal hover:bg-indigo-50 dark:hover:bg-indigo-900/20", | |
| value === option.value && "bg-indigo-50 dark:bg-indigo-900/20 font-medium", | |
| getStatusColor(option.value) | |
| )} | |
| onClick={() => { | |
| onValueChange(option.value as LeaveStatus | "All"); | |
| setOpen(false); | |
| }} | |
| > | |
| {option.label} | |
| </Button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Custom Type Filter component | |
| function CustomTypeFilter({ value, onValueChange }) { | |
| const [open, setOpen] = useState(false); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const typeOptions = [ | |
| { value: "All", label: "All Types" }, | |
| { value: "Annual", label: "Annual" }, | |
| { value: "Unpaid", label: "Unpaid" }, | |
| { value: "Compensatory", label: "Compensatory" }, | |
| { value: "Half Day", label: "Half Day" }, | |
| { value: "Other", label: "Other" } | |
| ]; | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => document.removeEventListener('mousedown', handleOutsideClick); | |
| }, [open]); | |
| const getTypeColor = (type: string) => { | |
| switch (type) { | |
| case "Annual": return "text-blue-500"; | |
| case "Sick": return "text-red-500"; | |
| case "Personal": return "text-green-500"; | |
| case "Unpaid": return "text-gray-500"; | |
| case "Maternity": return "text-pink-500"; | |
| case "Paternity": return "text-purple-500"; | |
| case "Bereavement": return "text-slate-500"; | |
| case "Compensatory": return "text-orange-500"; | |
| case "Half Day": return "text-amber-500"; | |
| case "Other": return "text-neutral-500"; | |
| default: return ""; | |
| } | |
| }; | |
| const selectedOption = typeOptions.find(option => option.value === value) || typeOptions[0]; | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <Button | |
| variant="outline" | |
| className="w-full h-10 justify-between bg-white dark:bg-gray-950" | |
| onClick={() => setOpen(!open)} | |
| > | |
| <div className="flex items-center"> | |
| <span className={cn(getTypeColor(value))}>{selectedOption.label}</span> | |
| </div> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </Button> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 w-full rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| {typeOptions.map((option) => ( | |
| <Button | |
| key={option.value} | |
| variant="ghost" | |
| className={cn( | |
| "w-full justify-start rounded-sm font-normal hover:bg-indigo-50 dark:hover:bg-indigo-900/20", | |
| value === option.value && "bg-indigo-50 dark:bg-indigo-900/20 font-medium", | |
| getTypeColor(option.value) | |
| )} | |
| onClick={() => { | |
| onValueChange(option.value as LeaveType | "All"); | |
| setOpen(false); | |
| }} | |
| > | |
| {option.label} | |
| </Button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Custom Year Filter component | |
| function CustomYearFilter({ value, onValueChange, years }) { | |
| const [open, setOpen] = useState(false); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => document.removeEventListener('mousedown', handleOutsideClick); | |
| }, [open]); | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <Button | |
| variant="outline" | |
| className="w-full h-10 justify-between bg-white dark:bg-gray-950" | |
| onClick={() => setOpen(!open)} | |
| > | |
| <div className="flex items-center"> | |
| <span>{value}</span> | |
| </div> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </Button> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 w-full rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| {years.map((year) => ( | |
| <Button | |
| key={year} | |
| variant="ghost" | |
| className={cn( | |
| "w-full justify-start rounded-sm font-normal hover:bg-indigo-50 dark:hover:bg-indigo-900/20", | |
| value === year && "bg-indigo-50 dark:bg-indigo-900/20 font-medium" | |
| )} | |
| onClick={() => { | |
| onValueChange(year.toString()); | |
| setOpen(false); | |
| }} | |
| > | |
| {year} | |
| </Button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const LeavesPage: React.FC = () => { | |
| const navigate = useNavigate(); | |
| const location = useLocation(); | |
| const { userData } = useAuth(); | |
| const isMobile = useIsMobile(); | |
| // Log userData for debugging | |
| console.log('LeavesPage - userData:', userData); | |
| const [isSidebarOpen, setIsSidebarOpen] = useState(!isMobile); | |
| // Close sidebar on mobile when route changes | |
| useEffect(() => { | |
| if (isMobile) { | |
| setIsSidebarOpen(false); | |
| } | |
| }, [location, isMobile]); | |
| // Update sidebar state when screen size changes | |
| useEffect(() => { | |
| setIsSidebarOpen(!isMobile); | |
| }, [isMobile]); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [leaves, setLeaves] = useState<Leave[]>([]); | |
| const [filteredLeaves, setFilteredLeaves] = useState<Leave[]>([]); | |
| const [selectedLeave, setSelectedLeave] = useState<Leave | null>(null); | |
| const [leaveBalance, setLeaveBalance] = useState<LeaveBalanceType[]>([]); | |
| const [isBalanceLoading, setIsBalanceLoading] = useState(true); | |
| const [apiError, setApiError] = useState<string | null>(null); | |
| const [leaveMasters, setLeaveMasters] = useState<LeaveMaster[]>([]); | |
| const [isLeaveMastersLoading, setIsLeaveMastersLoading] = useState(false); | |
| const [duplicateDateWarning, setDuplicateDateWarning] = useState<string>(""); | |
| // Dialog state | |
| const [showNewLeaveDialog, setShowNewLeaveDialog] = useState(false); | |
| const [isCompOffRequest, setIsCompOffRequest] = useState(false); | |
| const [isWFHRequest, setIsWFHRequest] = useState(false); | |
| const [showLeaveDetailDialog, setShowLeaveDetailDialog] = useState(false); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| // Filter states | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| const [statusFilter, setStatusFilter] = useState<LeaveStatus | "All">("All"); | |
| const [typeFilter, setTypeFilter] = useState<LeaveType | "All">("All"); | |
| const [yearFilter, setYearFilter] = useState<number>(getYear(new Date())); | |
| // 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 [activeTab, setActiveTab] = useState<string>("all"); | |
| // Helper function to clear query parameters | |
| const clearQueryParams = () => { | |
| navigate('/leaves', { replace: true }); | |
| }; | |
| // Handle query parameters to auto-open dialogs | |
| useEffect(() => { | |
| const searchParams = new URLSearchParams(location.search); | |
| const wfh = searchParams.get('wfh'); | |
| const compoff = searchParams.get('compoff'); | |
| if (wfh === 'true' && !showNewLeaveDialog) { | |
| setIsWFHRequest(true); | |
| setIsCompOffRequest(false); | |
| setShowNewLeaveDialog(true); | |
| } else if (compoff === 'true' && !showNewLeaveDialog) { | |
| setIsCompOffRequest(true); | |
| setIsWFHRequest(false); | |
| setShowNewLeaveDialog(true); | |
| } | |
| }, [location.search, showNewLeaveDialog]); | |
| useEffect(() => { | |
| const fetchLeaves = async () => { | |
| if (!userData?.employeeId) { | |
| console.log("No employee ID found in user data, skipping API call"); | |
| setIsLoading(false); | |
| return; | |
| } | |
| setIsLoading(true); | |
| setApiError(null); | |
| try { | |
| // Using employee ID 26 for testing as per the curl example | |
| const employeeId = userData?.employeeId; // Use hardcoded ID for testing | |
| console.log(`Fetching leaves for employee ID: ${employeeId} (testing with fixed ID)`); | |
| const leaves = await leaveApi.getByEmployeeId(employeeId); | |
| console.log(`Received ${leaves.length} leave records`); | |
| setLeaves(leaves); | |
| setFilteredLeaves(leaves); | |
| } 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); | |
| } | |
| }; | |
| const fetchLeaveBalance = async () => { | |
| setIsBalanceLoading(true); | |
| try { | |
| if (userData?.employeeId) { | |
| const data = await leaveApi.getLeaveBalance(userData.employeeId, currentYear); | |
| if (Array.isArray(data)) { | |
| setLeaveBalance(data); | |
| console.log('Leave balance data:', data); | |
| } else { | |
| console.error('Invalid leave balance data format:', data); | |
| setLeaveBalance([]); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error fetching leave balance:', error); | |
| setLeaveBalance([]); | |
| } finally { | |
| setIsBalanceLoading(false); | |
| } | |
| }; | |
| const fetchLeaveMasters = async () => { | |
| setIsLeaveMastersLoading(true); | |
| try { | |
| const masters = await leaveMasterApi.getAll(); | |
| setLeaveMasters(masters); | |
| console.log('Leave masters data:', masters); | |
| } catch (error) { | |
| console.error('Error fetching leave masters:', error); | |
| setLeaveMasters([]); | |
| } finally { | |
| setIsLeaveMastersLoading(false); | |
| } | |
| }; | |
| fetchLeaves(); | |
| fetchLeaveBalance(); | |
| fetchLeaveMasters(); | |
| }, [userData?.employeeId, currentYear]); | |
| // 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 based on active tab | |
| if (activeTab === "pending") { | |
| filtered = filtered.filter(leave => leave.status === "Pending"); | |
| } else if (activeTab === "approved") { | |
| filtered = filtered.filter(leave => leave.status === "Approved"); | |
| } else if (activeTab === "rejected") { | |
| filtered = filtered.filter(leave => leave.status === "Rejected"); | |
| } else if (statusFilter !== "All") { | |
| // Only apply statusFilter if not in a specific tab | |
| filtered = filtered.filter(leave => leave.status === statusFilter); | |
| } | |
| // Filter by type | |
| if (typeFilter !== "All") { | |
| filtered = filtered.filter(leave => leave.type === typeFilter); | |
| } | |
| // Filter by search term (reason) | |
| if (searchTerm) { | |
| filtered = filtered.filter(leave => | |
| leave.reason.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ); | |
| } | |
| setFilteredLeaves(filtered); | |
| }, [leaves, searchTerm, statusFilter, typeFilter, yearFilter, activeTab]); | |
| // Toggle sidebar | |
| const toggleSidebar = () => { | |
| setIsSidebarOpen(!isSidebarOpen); | |
| }; | |
| const handleSubmitLeave = async (data: LeaveCreateRequest) => { | |
| if (!userData?.employeeId) return; | |
| // Check for overlapping leave request date ranges | |
| const newStartDate = new Date(data.startDate); | |
| const newEndDate = new Date(data.endDate); | |
| const overlappingLeave = leaves.find(leave => { | |
| const existingStartDate = new Date(leave.startDate); | |
| const existingEndDate = new Date(leave.endDate); | |
| // Check if date ranges overlap | |
| // Overlap occurs if: new start <= existing end AND new end >= existing start | |
| return newStartDate <= existingEndDate && newEndDate >= existingStartDate; | |
| }); | |
| if (overlappingLeave && overlappingLeave.status !== "Cancelled") { | |
| const warningMsg = `This leave request overlaps with an existing leave from ${format(new Date(overlappingLeave.startDate), 'PPP')} to ${format(new Date(overlappingLeave.endDate), 'PPP')} (Status: ${overlappingLeave.status})`; | |
| setDuplicateDateWarning(warningMsg); | |
| toast.error("Leave request overlaps with an existing leave"); | |
| return; | |
| } | |
| setDuplicateDateWarning(""); | |
| setIsSubmitting(true); | |
| try { | |
| // Set the employee ID from the authenticated user and required fields | |
| data.id = 0; // Required by API | |
| data.employeeId = userData.employeeId; | |
| data.status = "Pending"; | |
| data.appliedOn = new Date().toISOString(); | |
| console.log("Submitting leave request with data:", data); | |
| // Submit the leave request | |
| const newLeave = await leaveApi.create(data); | |
| // Upload document if needed | |
| if (newLeave && data.documents) { | |
| // This part would need to be implemented if you have document upload functionality | |
| } | |
| // Refresh leaves list | |
| const updatedLeaves = await leaveApi.getByEmployeeId(userData.employeeId); | |
| setLeaves(updatedLeaves); | |
| // Close dialog | |
| setShowNewLeaveDialog(false); | |
| setIsCompOffRequest(false); | |
| setIsWFHRequest(false); | |
| clearQueryParams(); | |
| toast.success("Leave request submitted successfully"); | |
| } catch (error) { | |
| console.error("Error submitting leave request:", error); | |
| toast.error("Failed to submit leave request"); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| const handleCancelLeave = async (leave: Leave) => { | |
| if (!userData?.employeeId || !leave) return; | |
| if (window.confirm("Are you sure you want to cancel this leave request?")) { | |
| try { | |
| const updateData = { | |
| id: leave.id, | |
| status: "Cancelled" as LeaveStatus, | |
| approvedById: userData.employeeId, | |
| actionDate: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss"), | |
| comments: "Cancelled by employee", | |
| }; | |
| await leaveApi.updateStatus(updateData); | |
| // Refresh leaves list | |
| const updatedLeaves = await leaveApi.getByEmployeeId(userData.employeeId); | |
| setLeaves(updatedLeaves); | |
| toast.success("Leave request cancelled successfully"); | |
| } catch (error) { | |
| console.error("Error cancelling leave request:", error); | |
| toast.error("Failed to cancel leave request"); | |
| } | |
| } | |
| }; | |
| const handleViewLeaveDetails = (leave: Leave) => { | |
| setSelectedLeave(leave); | |
| setShowLeaveDetailDialog(true); | |
| }; | |
| const resetFilters = () => { | |
| setSearchTerm(""); | |
| setStatusFilter("All"); | |
| setTypeFilter("All"); | |
| setYearFilter(currentYear); | |
| }; | |
| return ( | |
| <div className="flex min-h-screen flex-col bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-gray-900 dark:to-gray-950"> | |
| <Header toggleSidebar={toggleSidebar} isSidebarOpen={isSidebarOpen} /> | |
| <Sidebar isOpen={isSidebarOpen} setIsOpen={setIsSidebarOpen} /> | |
| <main className={`flex-1 p-4 pt-20 md:p-8 md:pt-24 ${isSidebarOpen ? "md:ml-64" : ""} transition-all duration-300`}> | |
| <div className="mx-auto max-w-7xl"> | |
| {/* Header with title and action buttons */} | |
| <div className="mb-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm p-4 flex flex-col md:flex-row md:items-center justify-between gap-4"> | |
| <div> | |
| <h1 className="text-2xl font-bold flex items-center gap-2 text-indigo-600 dark:text-indigo-400"> | |
| <Briefcase className="h-7 w-7" /> | |
| <span>My Leave Management</span> | |
| </h1> | |
| <p className="text-gray-500 dark:text-gray-400 mt-1">Request and track your time off from work</p> | |
| </div> | |
| <div className="flex flex-wrap items-center gap-3"> | |
| {/* <Button | |
| className="flex items-center gap-2 bg-gradient-to-r from-teal-500 to-cyan-600 hover:from-teal-600 hover:to-cyan-700 text-white shadow-md border-none" | |
| onClick={() => { | |
| setIsWFHRequest(true); | |
| setIsCompOffRequest(false); | |
| setShowNewLeaveDialog(true); | |
| }} | |
| > | |
| <Home className="h-4 w-4" /> | |
| <span>Work From Home</span> | |
| </Button> | |
| <Button | |
| className="flex items-center gap-2 bg-gradient-to-r from-orange-500 to-amber-600 hover:from-orange-600 hover:to-amber-700 text-white shadow-md border-none" | |
| onClick={() => { | |
| setIsCompOffRequest(true); | |
| setIsWFHRequest(false); | |
| setShowNewLeaveDialog(true); | |
| }} | |
| > | |
| <CalendarPlus className="h-4 w-4" /> | |
| <span>Add Comp Off Request</span> | |
| </Button> */} | |
| <Button | |
| className="flex items-center gap-2 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white shadow-md border-none" | |
| onClick={() => { | |
| setIsCompOffRequest(false); | |
| setIsWFHRequest(false); | |
| setShowNewLeaveDialog(true); | |
| }} | |
| > | |
| <CalendarPlus className="h-4 w-4" /> | |
| <span>Request Leave</span> | |
| </Button> | |
| </div> | |
| <CustomDialog | |
| isOpen={showNewLeaveDialog} | |
| onClose={() => { | |
| if (!isSubmitting) { | |
| setShowNewLeaveDialog(false); | |
| setIsCompOffRequest(false); | |
| setIsWFHRequest(false); | |
| clearQueryParams(); | |
| } | |
| }} | |
| title={isWFHRequest ? "New Work From Home Request" : isCompOffRequest ? "New Comp Off Request" : "New Leave Request"} | |
| description={isWFHRequest ? "Submit a new work from home request for approval" : isCompOffRequest ? "Submit a new compensatory off request for approval" : "Submit a new leave request for approval"} | |
| allowClose={!isSubmitting} | |
| isPending={isSubmitting} | |
| > | |
| {userData && ( | |
| <LeaveForm | |
| employeeId={userData.employeeId} | |
| onSubmit={handleSubmitLeave} | |
| onCancel={() => { | |
| setShowNewLeaveDialog(false); | |
| setIsCompOffRequest(false); | |
| setIsWFHRequest(false); | |
| setDuplicateDateWarning(""); | |
| clearQueryParams(); | |
| }} | |
| isSubmitting={isSubmitting} | |
| defaultType={isWFHRequest ? "Work From Home" : isCompOffRequest ? "Compensatory" : ""} | |
| defaultCompOff={isCompOffRequest} | |
| hideLeaveType={isWFHRequest} | |
| duplicateDateWarning={duplicateDateWarning} | |
| onDateChange={() => setDuplicateDateWarning("")} | |
| /> | |
| )} | |
| </CustomDialog> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| {/* Left column for leave requests */} | |
| <div className="lg:col-span-2 space-y-6"> | |
| {/* Filters */} | |
| <Card className="shadow-md border-none"> | |
| <CardHeader className="pb-2"> | |
| <div className="flex items-center justify-between"> | |
| <CardTitle className="text-lg font-semibold flex items-center gap-2 text-indigo-600 dark:text-indigo-400"> | |
| <Filter className="h-4 w-4" /> | |
| <span>Filter Leaves</span> | |
| </CardTitle> | |
| {(searchTerm || statusFilter !== "All" || typeFilter !== "All" || yearFilter !== currentYear) && ( | |
| <Button variant="ghost" size="sm" onClick={resetFilters} className="h-8 gap-1 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"> | |
| <X className="h-3 w-3" /> | |
| <span>Reset</span> | |
| </Button> | |
| )} | |
| </div> | |
| </CardHeader> | |
| <CardContent className="pb-4"> | |
| <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> | |
| <div className="relative"> | |
| <SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search by reason..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="pl-8 bg-white dark:bg-gray-950" | |
| /> | |
| </div> | |
| <CustomStatusFilter | |
| value={statusFilter} | |
| onValueChange={(val) => setStatusFilter(val as LeaveStatus | "All")} | |
| /> | |
| <CustomTypeFilter | |
| value={typeFilter} | |
| onValueChange={(val) => setTypeFilter(val as LeaveType | "All")} | |
| /> | |
| <CustomYearFilter | |
| value={yearFilter} | |
| onValueChange={(val) => setYearFilter(parseInt(val))} | |
| years={years} | |
| /> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Tabs and leave list */} | |
| <Card className="shadow-md border-none overflow-hidden"> | |
| <CardHeader className="pb-0 pt-4 px-4"> | |
| <Tabs defaultValue="all" onValueChange={setActiveTab} className="w-full"> | |
| <TabsList className="grid grid-cols-4 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg"> | |
| <TabsTrigger | |
| value="all" | |
| className="data-[state=active]:bg-white dark:data-[state=active]:bg-gray-700 data-[state=active]:text-indigo-600 dark:data-[state=active]:text-indigo-400" | |
| > | |
| All | |
| </TabsTrigger> | |
| <TabsTrigger | |
| value="pending" | |
| className="data-[state=active]:bg-white dark:data-[state=active]:bg-gray-700 data-[state=active]:text-amber-500 dark:data-[state=active]:text-amber-400" | |
| > | |
| Pending | |
| </TabsTrigger> | |
| <TabsTrigger | |
| value="approved" | |
| className="data-[state=active]:bg-white dark:data-[state=active]:bg-gray-700 data-[state=active]:text-green-500 dark:data-[state=active]:text-green-400" | |
| > | |
| Approved | |
| </TabsTrigger> | |
| <TabsTrigger | |
| value="rejected" | |
| className="data-[state=active]:bg-white dark:data-[state=active]:bg-gray-700 data-[state=active]:text-red-500 dark:data-[state=active]:text-red-400" | |
| > | |
| Rejected | |
| </TabsTrigger> | |
| </TabsList> | |
| <TabsContent value="all" className="mt-4 mx-0 p-0"> | |
| {renderLeavesListEnhanced()} | |
| </TabsContent> | |
| <TabsContent value="pending" className="mt-4 mx-0 p-0"> | |
| {renderLeavesListEnhanced()} | |
| </TabsContent> | |
| <TabsContent value="approved" className="mt-4 mx-0 p-0"> | |
| {renderLeavesListEnhanced()} | |
| </TabsContent> | |
| <TabsContent value="rejected" className="mt-4 mx-0 p-0"> | |
| {renderLeavesListEnhanced()} | |
| </TabsContent> | |
| </Tabs> | |
| </CardHeader> | |
| </Card> | |
| </div> | |
| {/* Right column for leave balance and policies */} | |
| <div className="space-y-6"> | |
| {/* Enhanced Leave Balance Component */} | |
| <Card className="border-none shadow-md bg-gradient-to-br from-white to-blue-50 dark:from-gray-800 dark:to-gray-900"> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-lg flex items-center gap-2 text-blue-600 dark:text-blue-400"> | |
| <BarChart3 className="h-5 w-5" /> | |
| <span>Leave Balance ({currentYear})</span> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="pt-2"> | |
| {renderEnhancedLeaveBalance()} | |
| </CardContent> | |
| </Card> | |
| {/* Leave Policies Card */} | |
| <Card className="shadow-md border-none bg-gradient-to-br from-white to-purple-50 dark:from-gray-800 dark:to-gray-900"> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-lg flex items-center gap-2 text-purple-600 dark:text-purple-400"> | |
| <Info className="h-5 w-5" /> | |
| <span>Leave Policies</span> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="pt-2"> | |
| <ul className="space-y-3"> | |
| <li className="flex gap-2 items-start"> | |
| <span className="bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 p-1 rounded-full mt-0.5"> | |
| <Clock className="h-3 w-3" /> | |
| </span> | |
| <span className="text-sm">Leave requests must be submitted at least 3 days in advance</span> | |
| </li> | |
| <li className="flex gap-2 items-start"> | |
| <span className="bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-300 p-1 rounded-full mt-0.5"> | |
| <FileText className="h-3 w-3" /> | |
| </span> | |
| <span className="text-sm">Sick leave requires a medical certificate for 3+ days</span> | |
| </li> | |
| <li className="flex gap-2 items-start"> | |
| <span className="bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 p-1 rounded-full mt-0.5"> | |
| <TrendingUp className="h-3 w-3" /> | |
| </span> | |
| <span className="text-sm">Annual leave is accrued based on years of service</span> | |
| </li> | |
| <li className="flex gap-2 items-start"> | |
| <span className="bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-300 p-1 rounded-full mt-0.5"> | |
| <CalendarIcon className="h-3 w-3" /> | |
| </span> | |
| <span className="text-sm">Unused leave may be carried forward based on company policy</span> | |
| </li> | |
| <li className="flex gap-2 items-start"> | |
| <span className="bg-amber-100 dark:bg-amber-900 text-amber-600 dark:text-amber-300 p-1 rounded-full mt-0.5"> | |
| <User className="h-3 w-3" /> | |
| </span> | |
| <span className="text-sm">Contact HR for special leave circumstances</span> | |
| </li> | |
| </ul> | |
| </CardContent> | |
| </Card> | |
| {/* Additional Card: Quick Links */} | |
| <Card className="shadow-md border-none bg-gradient-to-br from-white to-emerald-50 dark:from-gray-800 dark:to-gray-900"> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-lg flex items-center gap-2 text-emerald-600 dark:text-emerald-400"> | |
| <CalendarIcon className="h-5 w-5" /> | |
| <span>Quick Links</span> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="pt-2"> | |
| <div className="space-y-2"> | |
| <Button | |
| variant="ghost" | |
| className="w-full justify-start text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/30" | |
| onClick={() => navigate('/holidays')} | |
| > | |
| <CalendarIcon className="mr-2 h-4 w-4" /> | |
| <span>Company Holidays</span> | |
| </Button> | |
| <Button variant="ghost" className="w-full justify-start text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"> | |
| <FileText className="mr-2 h-4 w-4" /> | |
| <a href="/Leave-Policy-SSPL.pdf" download className="flex items-center space-x-2 hover:scale-105 transition-transform cursor-pointer"> | |
| <span>Leave Policy Document</span> | |
| </a> | |
| </Button> | |
| <Button variant="ghost" className="w-full justify-start text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"> | |
| <Download className="mr-2 h-4 w-4" /> | |
| <a href="/SSPL-Holiday-List-2026.pdf" download className="flex items-center space-x-2 hover:scale-105 transition-transform cursor-pointer"> | |
| <span>Download Leave Calendar</span> | |
| </a> | |
| </Button> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Upcoming Holidays Card */} | |
| <Card className="shadow-md border-none bg-gradient-to-br from-white to-amber-50 dark:from-gray-800 dark:to-gray-900"> | |
| <CardHeader className="pb-2"> | |
| <CardTitle className="text-lg flex items-center gap-2 text-amber-600 dark:text-amber-400"> | |
| <Gift className="h-5 w-5" /> | |
| <span>Upcoming Holidays</span> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="pt-2"> | |
| <UpcomingHolidays /> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| {/* Leave Detail Dialog */} | |
| {selectedLeave && ( | |
| <CustomLeaveDetailDialog | |
| leave={selectedLeave} | |
| isOpen={showLeaveDetailDialog} | |
| onClose={() => setShowLeaveDetailDialog(false)} | |
| onDownloadDocument={leave => { | |
| if (leave.documents) { | |
| window.open(leave.documents, '_blank'); | |
| } | |
| }} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| function renderEnhancedLeaveBalance() { | |
| if (isBalanceLoading || isLeaveMastersLoading) { | |
| return ( | |
| <div className="flex items-center justify-center py-6"> | |
| <Loader2 className="h-8 w-8 animate-spin text-blue-500" /> | |
| </div> | |
| ); | |
| } | |
| if (!leaveBalance || leaveBalance.length === 0) { | |
| return ( | |
| <p className="text-center text-muted-foreground py-4"> | |
| No leave balance information available | |
| </p> | |
| ); | |
| } | |
| // Build dynamic leave type map from fetched leave masters | |
| const leaveTypeMap: Record<number, { label: string, color: string, bgColor: string, lightColor: string }> = {}; | |
| leaveMasters.forEach(master => { | |
| // Map leave types to colors (you can expand this mapping as needed) | |
| const getTypeColors = (type: string) => { | |
| switch (type.toLowerCase()) { | |
| case "annual": return { color: "bg-blue-500", bgColor: "bg-blue-100", lightColor: "text-blue-600" }; | |
| case "sick": return { color: "bg-red-500", bgColor: "bg-red-100", lightColor: "text-red-600" }; | |
| case "personal": return { color: "bg-green-500", bgColor: "bg-green-100", lightColor: "text-green-600" }; | |
| case "maternity": return { color: "bg-pink-500", bgColor: "bg-pink-100", lightColor: "text-pink-600" }; | |
| case "paternity": return { color: "bg-purple-500", bgColor: "bg-purple-100", lightColor: "text-purple-600" }; | |
| case "bereavement": return { color: "bg-slate-500", bgColor: "bg-slate-100", lightColor: "text-slate-600" }; | |
| case "compensatory": return { color: "bg-orange-500", bgColor: "bg-orange-100", lightColor: "text-orange-600" }; | |
| case "half day": return { color: "bg-amber-500", bgColor: "bg-amber-100", lightColor: "text-amber-600" }; | |
| case "casual": return { color: "bg-emerald-500", bgColor: "bg-emerald-100", lightColor: "text-emerald-600" }; | |
| case "unpaid": return { color: "bg-gray-500", bgColor: "bg-gray-100", lightColor: "text-gray-600" }; | |
| case "work from home": return { color: "bg-teal-500", bgColor: "bg-teal-100", lightColor: "text-teal-600" }; | |
| default: return { color: "bg-neutral-500", bgColor: "bg-neutral-100", lightColor: "text-neutral-600" }; | |
| } | |
| }; | |
| const colors = getTypeColors(master.leaveType); | |
| leaveTypeMap[master.id] = { | |
| label: master.leaveType, | |
| ...colors | |
| }; | |
| }); | |
| // Function to calculate percentage used | |
| const calculatePercentage = (used: number, total: number) => { | |
| if (total === 0) return 0; | |
| return Math.min((used / total) * 100, 100); // Cap at 100% | |
| }; | |
| return ( | |
| <div className="space-y-5"> | |
| {leaveBalance.map((balance) => { | |
| const typeInfo = leaveTypeMap[balance.leavesMasterId] || | |
| { label: `Leave Type ${balance.leavesMasterId}`, color: "bg-gray-500", bgColor: "bg-gray-100", lightColor: "text-gray-600" }; | |
| const percentage = calculatePercentage(balance.used, balance.allocated); | |
| const remaining = balance.pending; | |
| // Check if this is Annual or Compensatory leave type | |
| const isAnnualOrCompensatory = typeInfo.label.toLowerCase() === "annual" || typeInfo.label.toLowerCase() === "compensatory"; | |
| return ( | |
| <div key={balance.id} className="space-y-2"> | |
| <div className="flex justify-between items-center"> | |
| <div className="flex items-center gap-2"> | |
| <span className={`p-1.5 rounded-md ${typeInfo.bgColor}`}> | |
| <CalendarIcon className={`h-4 w-4 ${typeInfo.lightColor}`} /> | |
| </span> | |
| <span className="font-medium">{typeInfo.label}</span> | |
| </div> | |
| {isAnnualOrCompensatory ? ( | |
| <span className={`font-bold text-lg ${typeInfo.lightColor}`}> | |
| {remaining} <span className="text-sm font-normal text-gray-500">/ {balance.allocated}</span> | |
| </span> | |
| ) : ( | |
| <span className={`font-bold text-lg ${typeInfo.lightColor}`}> | |
| {balance.used} <span className="text-sm font-normal text-gray-500">used</span> | |
| </span> | |
| )} | |
| </div> | |
| {isAnnualOrCompensatory && ( | |
| <div className="relative h-2.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"> | |
| <div | |
| className={cn("h-full absolute left-0 top-0", typeInfo.color)} | |
| style={{ width: `${percentage}%` }} | |
| /> | |
| </div> | |
| )} | |
| <div className="flex justify-between text-xs text-gray-500"> | |
| {isAnnualOrCompensatory ? ( | |
| <> | |
| <span>Used: {balance.used} days</span> | |
| {balance.carryForward > 0 && ( | |
| <span>Includes {balance.carryForward} carried forward</span> | |
| )} | |
| </> | |
| ) : ( | |
| <span>Used: {balance.used} days</span> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| function renderLeavesListEnhanced() { | |
| if (isLoading) { | |
| return ( | |
| <div className="flex justify-center items-center py-8"> | |
| <Loader2 className="h-8 w-8 animate-spin text-indigo-500" /> | |
| </div> | |
| ); | |
| } | |
| if (apiError) { | |
| return ( | |
| <div className="text-center py-8 px-4 border border-red-200 rounded-lg bg-red-50 dark:bg-red-900/20 dark:border-red-800"> | |
| <XCircle className="h-8 w-8 mx-auto text-red-500 mb-2" /> | |
| <h3 className="font-medium text-lg text-red-700 dark:text-red-400">API Connection Error</h3> | |
| <p className="text-red-600 dark:text-red-300 mb-4 max-w-md mx-auto"> | |
| {apiError} | |
| </p> | |
| <Button | |
| className="bg-white text-red-600 border border-red-200 hover:bg-red-50" | |
| onClick={() => window.location.reload()} | |
| > | |
| <RefreshCw className="h-4 w-4 mr-2" /> | |
| Retry Connection | |
| </Button> | |
| </div> | |
| ); | |
| } | |
| if (filteredLeaves.length === 0) { | |
| return ( | |
| <div className="text-center py-10 px-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"> | |
| <CalendarIcon className="h-10 w-10 mx-auto text-gray-400 mb-3" /> | |
| <h3 className="font-medium text-lg text-gray-700 dark:text-gray-300">No leaves found</h3> | |
| <p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto mt-1"> | |
| No leave records found for the selected filters | |
| </p> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="grid gap-4 p-4"> | |
| {filteredLeaves.map((leave) => ( | |
| <EnhancedLeaveCard | |
| key={leave.id} | |
| leave={leave} | |
| onViewDetails={() => { | |
| setSelectedLeave(leave); | |
| setShowLeaveDetailDialog(true); | |
| }} | |
| onCancel={handleCancelLeave} | |
| /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| // A more colorful version of the leave card | |
| function EnhancedLeaveCard({ leave, onViewDetails, onCancel }: { | |
| leave: Leave, | |
| onViewDetails: () => void, | |
| onCancel?: (leave: Leave) => void | |
| }) { | |
| const startDate = new Date(leave.startDate); | |
| const endDate = new Date(leave.endDate); | |
| const durationDays = leave.workingDays || (differenceInCalendarDays(endDate, startDate) + 1); | |
| // Debug logging for updatedByName | |
| if (leave.status === "Approved" || leave.status === "Rejected") { | |
| console.log('Leave ID:', leave.id, 'Status:', leave.status, 'actionDate:', leave.actionDate, 'updatedByName:', leave.updatedByName, 'Full leave object:', leave); | |
| } | |
| // Get background and text colors based on leave type | |
| const getTypeColors = (type: LeaveType) => { | |
| switch (type) { | |
| case "Annual": | |
| return { bg: "bg-blue-50 dark:bg-blue-900/20", border: "border-blue-200 dark:border-blue-800", text: "text-blue-700 dark:text-blue-300" }; | |
| case "Sick": | |
| return { bg: "bg-red-50 dark:bg-red-900/20", border: "border-red-200 dark:border-red-800", text: "text-red-700 dark:text-red-300" }; | |
| case "Personal": | |
| return { bg: "bg-green-50 dark:bg-green-900/20", border: "border-green-200 dark:border-green-800", text: "text-green-700 dark:text-green-300" }; | |
| case "Unpaid": | |
| return { bg: "bg-orange-50 dark:bg-orange-900/20", border: "border-orange-200 dark:border-orange-800", text: "text-orange-700 dark:text-orange-300" }; | |
| case "Maternity": | |
| return { bg: "bg-pink-50 dark:bg-pink-900/20", border: "border-pink-200 dark:border-pink-800", text: "text-pink-700 dark:text-pink-300" }; | |
| case "Paternity": | |
| return { bg: "bg-purple-50 dark:bg-purple-900/20", border: "border-purple-200 dark:border-purple-800", text: "text-purple-700 dark:text-purple-300" }; | |
| case "Bereavement": | |
| return { bg: "bg-slate-50 dark:bg-slate-900/20", border: "border-slate-200 dark:border-slate-800", text: "text-slate-700 dark:text-slate-300" }; | |
| case "Compensatory": | |
| return { bg: "bg-amber-50 dark:bg-amber-900/20", border: "border-amber-200 dark:border-amber-800", text: "text-amber-700 dark:text-amber-300" }; | |
| case "Half Day": | |
| return { bg: "bg-yellow-50 dark:bg-yellow-900/20", border: "border-yellow-200 dark:border-yellow-800", text: "text-yellow-700 dark:text-yellow-300" }; | |
| default: | |
| return { bg: "bg-gray-50 dark:bg-gray-800", border: "border-gray-200 dark:border-gray-700", text: "text-gray-700 dark:text-gray-300" }; | |
| } | |
| }; | |
| // Get badge colors based on status | |
| const getStatusColors = (status: LeaveStatus) => { | |
| switch (status) { | |
| case "Approved": | |
| return { | |
| bg: "bg-green-100 dark:bg-green-900/30", | |
| text: "text-green-700 dark:text-green-300", | |
| icon: <CheckCircle className="h-4 w-4" /> | |
| }; | |
| case "Rejected": | |
| return { | |
| bg: "bg-red-100 dark:bg-red-900/30", | |
| text: "text-red-700 dark:text-red-300", | |
| icon: <XCircle className="h-4 w-4" /> | |
| }; | |
| case "Cancelled": | |
| return { | |
| bg: "bg-gray-100 dark:bg-gray-800", | |
| text: "text-gray-700 dark:text-gray-400", | |
| icon: <AlertCircle className="h-4 w-4" /> | |
| }; | |
| case "Pending": | |
| default: | |
| return { | |
| bg: "bg-amber-100 dark:bg-amber-900/30", | |
| text: "text-amber-700 dark:text-amber-300", | |
| icon: <Clock className="h-4 w-4" /> | |
| }; | |
| } | |
| }; | |
| const typeColors = getTypeColors(leave.type); | |
| const statusColors = getStatusColors(leave.status); | |
| return ( | |
| <div className={`rounded-lg border ${typeColors.border} ${typeColors.bg} overflow-hidden transition-all hover:shadow-md`}> | |
| <div className="p-4"> | |
| <div className="flex justify-between items-start mb-3"> | |
| <div> | |
| <h3 className={`font-semibold text-lg ${typeColors.text}`}> | |
| {leave.compOff ? "Comp Off Request" : `${leave.type} Leave`} | |
| </h3> | |
| <p className="text-gray-500 dark:text-gray-400 text-sm mt-0.5"> | |
| Applied on {format(new Date(leave.appliedOn), "MMM d, yyyy")} | |
| </p> | |
| </div> | |
| <div className={`px-2.5 py-1 rounded-full flex items-center gap-1.5 ${statusColors.bg} ${statusColors.text}`}> | |
| {statusColors.icon} | |
| <span className="text-sm font-medium">{leave.status}</span> | |
| </div> | |
| </div> | |
| <div className="space-y-2.5"> | |
| <div className="flex items-center gap-2"> | |
| <div className="p-1.5 rounded-full bg-gray-100 dark:bg-gray-800"> | |
| <CalendarIcon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> | |
| </div> | |
| <div> | |
| <span className="text-gray-800 dark:text-gray-200"> | |
| {format(startDate, "MMM d")} - {format(endDate, "MMM d, yyyy")} | |
| </span> | |
| <span className="ml-1.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-1.5 py-0.5 rounded-full"> | |
| {durationDays} {durationDays === 1 ? "day" : "days"} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="flex items-start gap-2"> | |
| <div className="p-1.5 rounded-full bg-gray-100 dark:bg-gray-800 mt-0.5"> | |
| <FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" /> | |
| </div> | |
| <span className="text-gray-700 dark:text-gray-300 line-clamp-2">{leave.reason}</span> | |
| </div> | |
| {leave.actionDate && ( | |
| <div className="flex items-center gap-2"> | |
| <div className="p-1.5 rounded-full bg-gray-100 dark:bg-gray-800"> | |
| <Clock className="h-4 w-4 text-gray-600 dark:text-gray-400" /> | |
| </div> | |
| <span className="text-sm text-gray-600 dark:text-gray-400"> | |
| {leave.status === "Approved" ? "Approved" : | |
| leave.status === "Rejected" ? "Rejected" : | |
| "Updated"} on {format(new Date(leave.actionDate), "MMM d, yyyy")} | |
| {leave.updatedByName && (leave.status === "Approved" || leave.status === "Rejected") && ( | |
| <span className="ml-1">by {leave.updatedByName}</span> | |
| )} | |
| </span> | |
| </div> | |
| )} | |
| {!leave.actionDate && leave.updatedByName && (leave.status === "Approved" || leave.status === "Rejected") && ( | |
| <div className="flex items-center gap-2"> | |
| <div className="p-1.5 rounded-full bg-gray-100 dark:bg-gray-800"> | |
| <User className="h-4 w-4 text-gray-600 dark:text-gray-400" /> | |
| </div> | |
| <span className="text-sm text-gray-600 dark:text-gray-400"> | |
| {leave.status === "Approved" ? "Approved" : "Rejected"} by {leave.updatedByName} | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex justify-end gap-2 mt-4"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={onViewDetails} | |
| className="border-gray-300 hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-700" | |
| > | |
| View Details | |
| </Button> | |
| {leave.status === "Pending" && onCancel && ( | |
| <Button | |
| variant="destructive" | |
| size="sm" | |
| onClick={() => onCancel(leave)} | |
| className="bg-red-100 text-red-700 border border-red-200 hover:bg-red-200 hover:text-red-800 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800 dark:hover:bg-red-900/50" | |
| > | |
| Cancel Request | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function renderLeavesList() { | |
| // Keep the original function for backwards compatibility | |
| 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"> | |
| <X 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 (filteredLeaves.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"> | |
| No leave records found for the selected filters | |
| </p> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="grid gap-4"> | |
| {filteredLeaves.map((leave) => ( | |
| <LeaveCard | |
| key={leave.id} | |
| leave={leave} | |
| onViewDetails={() => { | |
| setSelectedLeave(leave); | |
| setShowLeaveDetailDialog(true); | |
| }} | |
| onCancel={handleCancelLeave} | |
| /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| // Add Upcoming Holidays component | |
| function UpcomingHolidays() { | |
| const [holidays, setHolidays] = useState<Holiday[]>([]); | |
| const [isLoading, setIsLoading] = useState(true); | |
| useEffect(() => { | |
| async function fetchHolidays() { | |
| setIsLoading(true); | |
| try { | |
| const allHolidays = await holidayApi.getAll(); | |
| // Filter for upcoming holidays only (starting from today) | |
| const today = new Date(); | |
| today.setHours(0, 0, 0, 0); | |
| const upcomingHolidays = allHolidays | |
| .filter(holiday => { | |
| const holidayDate = new Date(holiday.date); | |
| return holidayDate >= today; | |
| }) | |
| .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) | |
| .slice(0, 5); // Show only the next 5 holidays | |
| setHolidays(upcomingHolidays); | |
| } catch (error) { | |
| console.error("Error fetching holidays:", error); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| } | |
| fetchHolidays(); | |
| }, []); | |
| // Helper function to get seasonal color based on date | |
| const getSeasonalColor = (dateStr: string): string => { | |
| const date = new Date(dateStr); | |
| const month = date.getMonth(); | |
| // Winter (December, January, February) | |
| if (month === 11 || month === 0 || month === 1) | |
| return "bg-blue-50 border-blue-200 text-blue-700 dark:bg-blue-900/30 dark:border-blue-800 dark:text-blue-300"; | |
| // Spring (March, April, May) | |
| if (month >= 2 && month <= 4) | |
| return "bg-green-50 border-green-200 text-green-700 dark:bg-green-900/30 dark:border-green-800 dark:text-green-300"; | |
| // Summer (June, July, August) | |
| if (month >= 5 && month <= 7) | |
| return "bg-amber-50 border-amber-200 text-amber-700 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-300"; | |
| // Fall (September, October, November) | |
| return "bg-orange-50 border-orange-200 text-orange-700 dark:bg-orange-900/30 dark:border-orange-800 dark:text-orange-300"; | |
| }; | |
| if (isLoading) { | |
| return ( | |
| <div className="flex justify-center items-center py-4"> | |
| <Loader2 className="h-6 w-6 animate-spin text-amber-500" /> | |
| </div> | |
| ); | |
| } | |
| if (holidays.length === 0) { | |
| return ( | |
| <div className="text-center py-4"> | |
| <p className="text-muted-foreground">No upcoming holidays found</p> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-3"> | |
| {holidays.map(holiday => { | |
| const holidayDate = new Date(holiday.date); | |
| const isRecurring = holiday.isRecurring; | |
| const colorClass = getSeasonalColor(holiday.date); | |
| return ( | |
| <div | |
| key={holiday.id} | |
| className={`p-3 rounded-lg border ${colorClass} transition-all hover:shadow-md`} | |
| > | |
| <div className="flex justify-between items-start"> | |
| <div> | |
| <h3 className="font-medium">{holiday.name}</h3> | |
| <p className="text-sm"> | |
| {format(holidayDate, "EEEE, MMMM d, yyyy")} | |
| {isRecurring && <span className="ml-1 text-xs">(Recurring)</span>} | |
| </p> | |
| </div> | |
| <div className="bg-white dark:bg-gray-800 px-2 py-1 rounded-full text-xs text-gray-600 dark:text-gray-300 shadow-sm"> | |
| {format(holidayDate, "MMM d")} | |
| </div> | |
| </div> | |
| {holiday.description && ( | |
| <p className="text-xs mt-1 text-muted-foreground line-clamp-2"> | |
| {holiday.description} | |
| </p> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="w-full mt-2 border-amber-200 hover:bg-amber-50 text-amber-700 dark:border-amber-800 dark:hover:bg-amber-900/30 dark:text-amber-400" | |
| onClick={() => navigate('/holidays')} | |
| > | |
| <CalendarDays className="mr-2 h-4 w-4" /> | |
| <span>View All Holidays</span> | |
| </Button> | |
| </div> | |
| ); | |
| } | |
| }; | |
| export default LeavesPage; |