pmtool / src /pages /leaves.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
57.7 kB
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;