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