Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from "react"; | |
| import { useForm } from "react-hook-form"; | |
| import { zodResolver } from "@hookform/resolvers/zod"; | |
| import { z } from "zod"; | |
| import { format, addDays, differenceInCalendarDays, eachDayOfInterval, isWeekend, isSameDay } from "date-fns"; | |
| import { Calendar } from "@/components/ui/calendar"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { Checkbox } from "@/components/ui/checkbox"; | |
| import { holidayApi } from "@/services/holidayApi"; | |
| import { LeaveType, LeaveCreateRequest, LeaveMaster, LeaveBalance, Holiday } from "@/types"; | |
| import { | |
| Form, | |
| FormControl, | |
| FormField, | |
| FormItem, | |
| FormLabel, | |
| FormMessage, | |
| FormDescription, | |
| } from "@/components/ui/form"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import { cn } from "@/lib/utils"; | |
| import { | |
| Calendar as CalendarIcon, | |
| Loader2, | |
| ChevronDown, | |
| Briefcase, | |
| Heart, | |
| Moon, | |
| Sun, | |
| Baby, | |
| DollarSign, | |
| HelpCircle, | |
| Check, | |
| Clock, | |
| FileText, | |
| Leaf, | |
| Droplets, | |
| AlertCircle, | |
| RefreshCw, | |
| Gift, | |
| CalendarDays, | |
| } from "lucide-react"; | |
| import { toast } from "sonner"; | |
| import { leaveMasterApi } from "@/services/leaveMasterApi"; | |
| import { leaveApi } from "@/services/leaveApi"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| // Create a more dynamic schema based on available leave types | |
| const createLeaveFormSchema = (availableTypes: string[]) => z.object({ | |
| startDate: z.date({ | |
| required_error: "Start date is required", | |
| }), | |
| endDate: z.date({ | |
| required_error: "End date is required", | |
| }), | |
| type: z.string({ | |
| required_error: "Leave type is required", | |
| }), | |
| reason: z.string().min(5, { | |
| message: "Reason must be at least 5 characters", | |
| }), | |
| documents: z.instanceof(FileList).optional(), | |
| halfDay: z.boolean().default(false), | |
| halfDayPortion: z.enum(['first', 'second']).optional(), | |
| leaveMasterId: z.number().optional(), | |
| compOff: z.boolean().default(false), | |
| dayType: z.enum(['half', 'full']).default('full'), | |
| }); | |
| type LeaveFormValues = { | |
| startDate: Date; | |
| endDate: Date; | |
| type: string; | |
| reason: string; | |
| documents?: FileList; | |
| halfDay: boolean; | |
| halfDayPortion?: 'first' | 'second'; | |
| leaveMasterId?: number; | |
| compOff: boolean; | |
| dayType: 'half' | 'full'; | |
| }; | |
| interface LeaveFormProps { | |
| employeeId: number; | |
| onSubmit: (data: LeaveCreateRequest) => Promise<void>; | |
| onCancel: () => void; | |
| isSubmitting: boolean; | |
| defaultType?: string; | |
| defaultCompOff?: boolean; | |
| hideLeaveType?: boolean; | |
| duplicateDateWarning?: string; | |
| onDateChange?: () => void; | |
| } | |
| const LeaveForm: React.FC<LeaveFormProps> = ({ | |
| employeeId, | |
| onSubmit, | |
| onCancel, | |
| isSubmitting, | |
| defaultType = "", | |
| defaultCompOff = false, | |
| hideLeaveType = false, | |
| duplicateDateWarning = "", | |
| onDateChange, | |
| }) => { | |
| const { userData } = useAuth(); | |
| const [selectedFile, setSelectedFile] = useState<File | null>(null); | |
| const [showStartDateCalendar, setShowStartDateCalendar] = useState(false); | |
| const [showEndDateCalendar, setShowEndDateCalendar] = useState(false); | |
| const [showTypeDropdown, setShowTypeDropdown] = useState(false); | |
| const [showLeaveMasterDropdown, setShowLeaveMasterDropdown] = useState(false); | |
| const [leaveMasters, setLeaveMasters] = useState<LeaveMaster[]>([]); | |
| const [isLoadingMasters, setIsLoadingMasters] = useState(false); | |
| const [availableLeaveTypes, setAvailableLeaveTypes] = useState<string[]>([]); | |
| const [leaveBalances, setLeaveBalances] = useState<LeaveBalance[]>([]); | |
| const [isLoadingBalances, setIsLoadingBalances] = useState(false); | |
| const [currentYear] = useState<number>(new Date().getFullYear()); | |
| const [showHalfDayDropdown, setShowHalfDayDropdown] = useState(false); | |
| const [holidays, setHolidays] = useState<Holiday[]>([]); | |
| const [calculatedWorkingDays, setCalculatedWorkingDays] = useState<number>(0); | |
| const [holidayWarnings, setHolidayWarnings] = useState<string[]>([]); | |
| // Add refs for the date pickers | |
| const startDateRef = useRef<HTMLDivElement>(null); | |
| const endDateRef = useRef<HTMLDivElement>(null); | |
| const typeDropdownRef = useRef<HTMLDivElement>(null); | |
| const leaveMasterDropdownRef = useRef<HTMLDivElement>(null); | |
| const halfDayDropdownRef = useRef<HTMLDivElement>(null); | |
| // Fallback leave types in case the API fails | |
| const fallbackLeaveTypes = [ | |
| "Annual", | |
| "Sick", | |
| "Personal", | |
| "Unpaid", | |
| "Maternity", | |
| "Paternity", | |
| "Other" | |
| ]; | |
| const form = useForm<LeaveFormValues>({ | |
| resolver: zodResolver(createLeaveFormSchema(availableLeaveTypes.length > 0 ? availableLeaveTypes : fallbackLeaveTypes)), | |
| defaultValues: { | |
| startDate: new Date(), | |
| endDate: addDays(new Date(), 1), | |
| type: defaultType, | |
| reason: "", | |
| halfDay: false, | |
| halfDayPortion: 'first', | |
| leaveMasterId: undefined, | |
| compOff: defaultCompOff, | |
| dayType: 'full', | |
| }, | |
| }); | |
| // Fetch leave masters and leave balances on component mount | |
| useEffect(() => { | |
| const fetchData = async () => { | |
| setIsLoadingMasters(true); | |
| setIsLoadingBalances(true); | |
| try { | |
| // Get all leave masters | |
| const masters = await leaveMasterApi.getAll(); | |
| console.log('Fetched all leave masters:', masters); | |
| // Filter leave masters based on user role if available | |
| let filteredMasters = masters; | |
| if (userData?.roleId) { | |
| filteredMasters = masters.filter(master => { | |
| const roles = master.applyToRoles.split(',').map(r => parseInt(r.trim(), 10)); | |
| return roles.includes(userData.roleId) && master.isActive; | |
| }); | |
| console.log(`Filtered leave masters for role ${userData.roleId}:`, filteredMasters); | |
| } else { | |
| // Only use active leave masters | |
| filteredMasters = masters.filter(master => master.isActive); | |
| } | |
| setLeaveMasters(filteredMasters); | |
| // Extract available leave types from the masters | |
| const types = filteredMasters.map(m => m.leaveType); | |
| setAvailableLeaveTypes(types); | |
| // Set default selection if available | |
| if (filteredMasters.length > 0) { | |
| // Priority: 1. defaultType if provided, 2. Annual, 3. First available | |
| const defaultMaster = | |
| (defaultType && filteredMasters.find(m => m.leaveType.toLowerCase() === defaultType.toLowerCase())) || | |
| filteredMasters.find(m => m.leaveType === 'Annual') || | |
| filteredMasters[0]; | |
| form.setValue('type', defaultMaster.leaveType); | |
| form.setValue('leaveMasterId', defaultMaster.id); | |
| // If it's compensatory and defaultCompOff is true, ensure it's set | |
| if (defaultCompOff && defaultMaster.leaveType.toLowerCase() === "compensatory") { | |
| form.setValue('compOff', true); | |
| } | |
| } | |
| // Get leave balances for the current employee | |
| if (employeeId) { | |
| try { | |
| const balances = await leaveApi.getLeaveBalance(employeeId, currentYear); | |
| console.log(`Fetched leave balances for employee ${employeeId}:`, balances); | |
| if (Array.isArray(balances)) { | |
| setLeaveBalances(balances); | |
| } | |
| } catch (error) { | |
| console.error('Error fetching leave balances:', error); | |
| toast.error('Failed to load leave balances'); | |
| } | |
| // Get holidays | |
| try { | |
| const fetchedHolidays = await holidayApi.getAll(); | |
| setHolidays(fetchedHolidays); | |
| } catch (error) { | |
| console.error('Error fetching holidays:', error); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error fetching leave masters:', error); | |
| toast.error('Failed to load leave types'); | |
| setAvailableLeaveTypes(fallbackLeaveTypes); | |
| } finally { | |
| setIsLoadingMasters(false); | |
| setIsLoadingBalances(false); | |
| } | |
| }; | |
| fetchData(); | |
| }, [userData?.roleId, employeeId, currentYear]); | |
| // Update the useEffect for click outside handling | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (startDateRef.current && !startDateRef.current.contains(event.target as Node)) { | |
| setShowStartDateCalendar(false); | |
| } | |
| if (endDateRef.current && !endDateRef.current.contains(event.target as Node)) { | |
| setShowEndDateCalendar(false); | |
| } | |
| if (typeDropdownRef.current && !typeDropdownRef.current.contains(event.target as Node)) { | |
| setShowTypeDropdown(false); | |
| } | |
| if (leaveMasterDropdownRef.current && !leaveMasterDropdownRef.current.contains(event.target as Node)) { | |
| setShowLeaveMasterDropdown(false); | |
| } | |
| if (halfDayDropdownRef.current && !halfDayDropdownRef.current.contains(event.target as Node)) { | |
| setShowHalfDayDropdown(false); | |
| } | |
| }; | |
| document.addEventListener("mousedown", handleClickOutside); | |
| return () => { | |
| document.removeEventListener("mousedown", handleClickOutside); | |
| }; | |
| }, []); | |
| const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | |
| const files = event.target.files; | |
| if (files && files.length > 0) { | |
| setSelectedFile(files[0]); | |
| } | |
| }; | |
| // Helper function to find leave master ID based on leave type | |
| const findLeaveMasterId = (type: string): number => { | |
| // Try to find the matching leave master from fetched masters | |
| const master = leaveMasters.find(m => | |
| m.leaveType.toLowerCase() === type.toLowerCase() | |
| ); | |
| if (master) { | |
| return master.id; | |
| } | |
| // No matching master found, return a default | |
| return 0; | |
| }; | |
| // Add an effect to automatically set halfDay to true when the leave type is "Half Day" | |
| useEffect(() => { | |
| const selectedMasterId = form.watch('leaveMasterId'); | |
| if (selectedMasterId) { | |
| const selectedMaster = leaveMasters.find(m => m.id === selectedMasterId); | |
| if (selectedMaster && selectedMaster.leaveType.toLowerCase() === "half day") { | |
| form.setValue('halfDay', true); | |
| } else { | |
| form.setValue('halfDay', false); | |
| } | |
| // Only set compOff to false when changing away from Compensatory | |
| // Don't automatically set it to true just because Compensatory is selected | |
| // It should only be true when explicitly creating a Comp Off Request (defaultCompOff prop) | |
| if (selectedMaster && selectedMaster.leaveType.toLowerCase() !== "compensatory") { | |
| form.setValue('compOff', false); | |
| } | |
| } | |
| }, [form.watch('leaveMasterId'), leaveMasters]); | |
| // Add an effect to ensure end date equals start date for Half Day leave | |
| useEffect(() => { | |
| const selectedMasterId = form.watch('leaveMasterId'); | |
| const startDate = form.watch('startDate'); | |
| if (selectedMasterId) { | |
| const selectedMaster = leaveMasters.find(m => m.id === selectedMasterId); | |
| // For Half Day leave type, always set end date equal to start date | |
| if (selectedMaster && selectedMaster.leaveType.toLowerCase() === "half day") { | |
| form.setValue('halfDay', true); | |
| form.setValue('endDate', startDate); | |
| // Disable end date calendar for Half Day leave type | |
| setShowEndDateCalendar(false); | |
| } | |
| } | |
| }, [form.watch('startDate'), form.watch('leaveMasterId'), leaveMasters]); | |
| // Calculate working days whenever dates or leave type changes | |
| useEffect(() => { | |
| const startDate = form.watch("startDate"); | |
| const endDate = form.watch("endDate"); | |
| const leaveMasterId = form.watch("leaveMasterId"); | |
| const compOff = form.watch("compOff"); | |
| const dayType = form.watch("dayType"); | |
| console.log("Working days calculation triggered:", { | |
| startDate: startDate?.toDateString(), | |
| endDate: endDate?.toDateString(), | |
| leaveMasterId, | |
| compOff, | |
| dayType, | |
| holidaysCount: holidays.length, | |
| }); | |
| if (startDate && endDate && leaveMasterId) { | |
| const selectedMaster = leaveMasters.find((m) => m.id === leaveMasterId); | |
| const isHalfDayLeave = selectedMaster?.leaveType.toLowerCase() === "half day"; | |
| const isCompOffLeave = | |
| compOff || selectedMaster?.leaveType.toLowerCase() === "compensatory"; | |
| console.log("Leave type checks:", { | |
| selectedMaster: selectedMaster?.leaveType, | |
| isHalfDayLeave, | |
| isCompOffLeave, | |
| dayType, | |
| }); | |
| if (isHalfDayLeave || dayType === "half") { | |
| console.log("Setting 0.5 days for half day leave"); | |
| setCalculatedWorkingDays(0.5); | |
| setHolidayWarnings([]); | |
| } else { | |
| let actualWorkingDays = 0; | |
| try { | |
| // Normalize both dates to midnight to avoid time comparison issues | |
| const validStartDate = new Date(startDate); | |
| const validEndDate = new Date(endDate); | |
| validStartDate.setHours(0, 0, 0, 0); | |
| validEndDate.setHours(0, 0, 0, 0); | |
| console.log("Validating dates:", { | |
| originalStart: startDate, | |
| originalEnd: endDate, | |
| normalizedStart: validStartDate, | |
| normalizedEnd: validEndDate, | |
| isValidStart: !isNaN(validStartDate.getTime()), | |
| isValidEnd: !isNaN(validEndDate.getTime()), | |
| }); | |
| if (isNaN(validStartDate.getTime()) || isNaN(validEndDate.getTime())) { | |
| console.error("Invalid dates detected"); | |
| setCalculatedWorkingDays(0); | |
| setHolidayWarnings([]); | |
| return; | |
| } | |
| // Ensure start date is not after end date after normalization | |
| if (validStartDate > validEndDate) { | |
| console.error("Start date is after end date after normalization"); | |
| setCalculatedWorkingDays(0); | |
| setHolidayWarnings([]); | |
| return; | |
| } | |
| const days = eachDayOfInterval({ | |
| start: validStartDate, | |
| end: validEndDate, | |
| }); | |
| console.log( | |
| "Days in interval:", | |
| days.length, | |
| days.map((d) => d.toDateString()) | |
| ); | |
| const foundHolidays: string[] = []; | |
| days.forEach((day) => { | |
| const isWeekendDay = isWeekend(day); | |
| const holiday = holidays.find((h) => | |
| isSameDay(new Date(h.date), day) | |
| ); | |
| // For all leave types (including regular leaves), count all days except holidays | |
| if (!holiday) { | |
| actualWorkingDays++; | |
| } else { | |
| const holidayInfo = `${holiday.name} (${format( | |
| new Date(holiday.date), | |
| "MMM d" | |
| )})`; | |
| if (!foundHolidays.includes(holidayInfo)) { | |
| foundHolidays.push(holidayInfo); | |
| } | |
| } | |
| }); | |
| console.log("Final calculated working days:", actualWorkingDays); | |
| setCalculatedWorkingDays(actualWorkingDays); | |
| setHolidayWarnings(foundHolidays); | |
| } catch (error) { | |
| console.error("Error calculating working days:", error); | |
| setCalculatedWorkingDays(0); | |
| setHolidayWarnings([]); | |
| } | |
| } | |
| } else { | |
| console.log("Missing required data, setting 0 days"); | |
| setCalculatedWorkingDays(0); | |
| setHolidayWarnings([]); | |
| } | |
| }, [ | |
| form.watch("startDate"), | |
| form.watch("endDate"), | |
| form.watch("leaveMasterId"), | |
| form.watch("compOff"), | |
| form.watch("dayType"), | |
| holidays, | |
| leaveMasters, | |
| ]); | |
| // Clear end date error when dates are valid | |
| useEffect(() => { | |
| const startDate = form.watch('startDate'); | |
| const endDate = form.watch('endDate'); | |
| if (startDate && endDate) { | |
| // Normalize both dates to midnight for accurate comparison | |
| const normalizedStart = new Date(startDate); | |
| const normalizedEnd = new Date(endDate); | |
| normalizedStart.setHours(0, 0, 0, 0); | |
| normalizedEnd.setHours(0, 0, 0, 0); | |
| console.log('Date comparison:', { | |
| originalStart: startDate, | |
| originalEnd: endDate, | |
| normalizedStart, | |
| normalizedEnd, | |
| comparison: normalizedStart <= normalizedEnd | |
| }); | |
| if (normalizedStart <= normalizedEnd) { | |
| if (form.formState.errors.endDate) { | |
| console.log('Clearing end date error'); | |
| form.clearErrors("endDate"); | |
| } | |
| } | |
| } | |
| }, [form.watch('startDate'), form.watch('endDate')]); | |
| // Update the handleSubmit function to handle working days calculation correctly for Half Day leave type | |
| const handleSubmit = async (values: LeaveFormValues) => { | |
| // Clear any previous end date error | |
| if (form.formState.errors.endDate) { | |
| form.clearErrors("endDate"); | |
| } | |
| // Validate end date is not before start date (allow same date) | |
| // Normalize both dates to midnight for accurate comparison | |
| const normalizedStart = new Date(values.startDate); | |
| const normalizedEnd = new Date(values.endDate); | |
| normalizedStart.setHours(0, 0, 0, 0); | |
| normalizedEnd.setHours(0, 0, 0, 0); | |
| console.log('Submit validation:', { | |
| originalStart: values.startDate, | |
| originalEnd: values.endDate, | |
| normalizedStart, | |
| normalizedEnd, | |
| isValid: normalizedEnd >= normalizedStart, | |
| dayType: values.dayType | |
| }); | |
| if (normalizedEnd < normalizedStart) { | |
| form.setError("endDate", { | |
| type: "manual", | |
| message: "End date cannot be before start date", | |
| }); | |
| return; | |
| } | |
| try { | |
| // Calculate working days | |
| const selectedMaster = leaveMasters.find(m => m.id === values.leaveMasterId); | |
| const isHalfDayLeave = selectedMaster?.leaveType.toLowerCase() === "half day"; | |
| const isCompOffLeave = values.compOff || selectedMaster?.leaveType.toLowerCase() === "compensatory"; | |
| let actualWorkingDays = 0; | |
| const days = eachDayOfInterval({ start: normalizedStart, end: normalizedEnd }); | |
| days.forEach(day => { | |
| const isWeekendDay = isWeekend(day); | |
| const isHoliday = holidays.some(h => isSameDay(new Date(h.date), day)); | |
| // For comp off requests, count weekends as working days (since they worked on weekends) | |
| // For regular leaves, exclude weekends and holidays | |
| if (isCompOffLeave) { | |
| // For comp off, count all days including weekends, but exclude holidays | |
| if (!isHoliday) { | |
| actualWorkingDays++; | |
| } | |
| } else { | |
| // For regular leaves, exclude both weekends and holidays | |
| if (!isWeekendDay && !isHoliday) { | |
| actualWorkingDays++; | |
| } | |
| } | |
| }); | |
| // If it's Half Day leave type or dayType is 'half', count it as 0.5 days, otherwise use full days | |
| const workingDays = (isHalfDayLeave || values.dayType === 'half') ? 0.5 : actualWorkingDays; | |
| // Concat first or second half in leave reason for halfday leave (excluding Comp Off) | |
| let finalReason = values.reason; | |
| if (isHalfDayLeave && values.halfDayPortion) { | |
| const portionLabel = values.halfDayPortion === 'first' ? 'First Half' : 'Second Half'; | |
| finalReason = `(${portionLabel}) ${values.reason}`; | |
| } | |
| const leaveData: LeaveCreateRequest = { | |
| id: 0, | |
| employeeId, | |
| leaveMasterId: values.leaveMasterId || findLeaveMasterId(values.type), | |
| startDate: format(values.startDate, "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"), | |
| endDate: format(values.endDate, "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"), | |
| reason: finalReason, | |
| type: values.type as LeaveType, | |
| status: "Pending", | |
| appliedOn: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"), | |
| comments: "", | |
| halfDay: isHalfDayLeave, | |
| halfDayPortion: (isHalfDayLeave || values.dayType === 'half') ? values.halfDayPortion : undefined, | |
| workingDays: workingDays, | |
| compOff: values.compOff, | |
| }; | |
| // If a document was attached, we'll handle it separately | |
| if (selectedFile) { | |
| leaveData.documents = "pending_upload"; // This will be replaced with the actual URL after upload | |
| } | |
| await onSubmit(leaveData); | |
| } catch (error) { | |
| console.error("Error submitting leave request:", error); | |
| toast.error("Failed to submit leave request"); | |
| } | |
| }; | |
| // Helper function to get icon for leave type | |
| const getLeaveTypeIcon = (type: string) => { | |
| switch (type.toLowerCase()) { | |
| case "annual": | |
| return <Sun className="h-4 w-4 mr-2" />; | |
| case "sick": | |
| return <Heart className="h-4 w-4 mr-2" />; | |
| case "personal": | |
| return <Briefcase className="h-4 w-4 mr-2" />; | |
| case "unpaid": | |
| return <DollarSign className="h-4 w-4 mr-2" />; | |
| case "maternity": | |
| return <Baby className="h-4 w-4 mr-2" />; | |
| case "paternity": | |
| return <Baby className="h-4 w-4 mr-2" />; | |
| case "compensatory": | |
| return <FileText className="h-4 w-4 mr-2" />; | |
| case "bereavement": | |
| return <FileText className="h-4 w-4 mr-2" />; | |
| case "half day": | |
| return <Clock className="h-4 w-4 mr-2" />; | |
| case "casual": | |
| return <Leaf className="h-4 w-4 mr-2" />; | |
| default: | |
| return <HelpCircle className="h-4 w-4 mr-2" />; | |
| } | |
| }; | |
| // 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"; | |
| } | |
| }; | |
| // Helper function to get color based on days available | |
| const getLeavePolicyColor = (days: number): string => { | |
| if (days >= 15) return "bg-green-100 text-green-800"; | |
| if (days >= 7) return "bg-blue-100 text-blue-800"; | |
| if (days >= 3) return "bg-yellow-100 text-yellow-800"; | |
| return "bg-red-100 text-red-800"; | |
| }; | |
| // Helper function to get leave balance for selected leave type | |
| const getLeaveBalance = (leaveMasterId: number): LeaveBalance | undefined => { | |
| return leaveBalances.find(balance => balance.leavesMasterId === leaveMasterId); | |
| }; | |
| // Add this helper function to check if currently selected leave type is Half Day | |
| const isHalfDayLeaveType = (): boolean => { | |
| const selectedMasterId = form.watch('leaveMasterId'); | |
| if (!selectedMasterId) return false; | |
| const selectedMaster = leaveMasters.find(m => m.id === selectedMasterId); | |
| return selectedMaster?.leaveType.toLowerCase() === "half day"; | |
| }; | |
| // Helper function to check if leave includes weekends (sandwich leave) | |
| const isSandwichLeave = (): boolean => { | |
| const startDate = form.watch('startDate'); | |
| const endDate = form.watch('endDate'); | |
| if (!startDate || !endDate) return false; | |
| // Ensure start date is not after end date to avoid Invalid interval error | |
| if (startDate > endDate) return false; | |
| // Check if the leave period includes any weekend days | |
| const days = eachDayOfInterval({ start: startDate, end: endDate }); | |
| // Check if any day in the period is a weekend (Saturday = 6, Sunday = 0) | |
| return days.some(day => isWeekend(day)); | |
| }; | |
| return ( | |
| <Form {...form}> | |
| <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-24"> | |
| {/* Duplicate Date Warning */} | |
| {duplicateDateWarning && ( | |
| <div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-4 flex items-start gap-3 animate-pulse"> | |
| <AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" /> | |
| <div className="flex-1"> | |
| <h4 className="font-semibold text-red-800 dark:text-red-300 mb-1">Duplicate Leave Request</h4> | |
| <p className="text-sm text-red-700 dark:text-red-400">{duplicateDateWarning}</p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Holiday Warning */} | |
| {holidayWarnings.length > 0 && ( | |
| <div className="bg-indigo-50 dark:bg-indigo-900/20 border-2 border-indigo-500 rounded-lg p-4 flex items-start gap-3"> | |
| <Gift className="h-5 w-5 text-indigo-600 dark:text-indigo-400 flex-shrink-0 mt-0.5" /> | |
| <div className="flex-1"> | |
| <h4 className="font-semibold text-indigo-800 dark:text-indigo-300 mb-1">Holiday Intersection</h4> | |
| <p className="text-sm text-indigo-700 dark:text-indigo-400"> | |
| The selected date range includes the following holiday(s): <strong>{holidayWarnings.join(", ")}</strong>. | |
| These days will not be deducted from your leave balance. | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Sandwich Leave Policy Warning */} | |
| {isSandwichLeave() && ( | |
| <div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-4 flex items-start gap-3"> | |
| <AlertCircle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" /> | |
| <div className="flex-1"> | |
| <h4 className="font-semibold text-amber-800 dark:text-amber-300 mb-1">Sandwich Leave Policy</h4> | |
| <p className="text-sm text-amber-700 dark:text-amber-400"> | |
| Your leave request includes a weekend between Friday and Monday. According to the sandwich leave policy, | |
| the weekend days (Saturday and Sunday) will be counted as part of your leave balance. | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| {/* Start Date Picker */} | |
| <div className="space-y-2"> | |
| <FormLabel htmlFor="startDate">Start Date</FormLabel> | |
| <div ref={startDateRef} className="relative"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| className="w-full justify-start text-left font-normal" | |
| onClick={() => { | |
| setShowEndDateCalendar(false); | |
| setShowStartDateCalendar(!showStartDateCalendar); | |
| }} | |
| > | |
| <CalendarIcon className="mr-2 h-4 w-4" /> | |
| {form.getValues("startDate") | |
| ? format(form.getValues("startDate"), "PPP") | |
| : <span>Pick a start date</span>} | |
| </Button> | |
| {showStartDateCalendar && ( | |
| <div | |
| className="absolute top-full left-0 mt-2 bg-background border rounded-lg shadow-md p-3 z-[100]" | |
| style={{ minWidth: '320px' }} | |
| > | |
| <Calendar | |
| mode="single" | |
| selected={form.getValues("startDate")} | |
| onSelect={(date) => { | |
| if (date) { | |
| form.setValue("startDate", date); | |
| setShowStartDateCalendar(false); | |
| // Clear duplicate warning when date changes | |
| if (onDateChange) { | |
| onDateChange(); | |
| } | |
| } | |
| }} | |
| initialFocus | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| {form.formState.errors.startDate && ( | |
| <p className="text-sm font-medium text-destructive"> | |
| {form.formState.errors.startDate.message} | |
| </p> | |
| )} | |
| </div> | |
| {/* End Date Picker */} | |
| <div className="space-y-2"> | |
| <FormLabel htmlFor="endDate">End Date</FormLabel> | |
| <div ref={endDateRef} className="relative"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| className={cn( | |
| "w-full justify-start text-left font-normal", | |
| // Add disabled styling when Half Day is selected | |
| (isHalfDayLeaveType() || form.watch('dayType') === 'half') && "opacity-60 cursor-not-allowed" | |
| )} | |
| onClick={() => { | |
| // Only allow opening the calendar if not Half Day leave type or dayType is not half | |
| if (!isHalfDayLeaveType() && form.watch('dayType') !== 'half') { | |
| setShowStartDateCalendar(false); | |
| setShowEndDateCalendar(!showEndDateCalendar); | |
| } | |
| }} | |
| disabled={isHalfDayLeaveType() || form.watch('dayType') === 'half'} | |
| > | |
| <CalendarIcon className="mr-2 h-4 w-4" /> | |
| {form.getValues("endDate") | |
| ? format(form.getValues("endDate"), "PPP") | |
| : <span>Pick an end date</span>} | |
| </Button> | |
| {showEndDateCalendar && !isHalfDayLeaveType() && form.watch('dayType') !== 'half' && ( | |
| <div | |
| className="absolute top-full left-0 mt-2 bg-background border rounded-lg shadow-md p-3 z-[100]" | |
| style={{ minWidth: '320px' }} | |
| > | |
| <Calendar | |
| mode="single" | |
| selected={form.getValues("endDate")} | |
| onSelect={(date) => { | |
| if (date) { | |
| form.setValue("endDate", date); | |
| setShowEndDateCalendar(false); | |
| } | |
| }} | |
| disabled={(date) => { | |
| const startDate = form.getValues("startDate"); | |
| // Normalize both dates to midnight for proper comparison | |
| const normalizedStartDate = new Date(startDate); | |
| normalizedStartDate.setHours(0, 0, 0, 0); | |
| const normalizedDate = new Date(date); | |
| normalizedDate.setHours(0, 0, 0, 0); | |
| // Allow same day selection by only disabling dates strictly before start date | |
| return normalizedDate < normalizedStartDate; | |
| }} | |
| initialFocus | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| {form.formState.errors.endDate && ( | |
| <p className="text-sm font-medium text-destructive"> | |
| {form.formState.errors.endDate.message} | |
| </p> | |
| )} | |
| {(isHalfDayLeaveType() || form.watch('dayType') === 'half') && ( | |
| <p className="text-xs text-muted-foreground"> | |
| End date is automatically set to match start date for Half Day leave | |
| </p> | |
| )} | |
| {/* Calculated Working Days Display */} | |
| {form.watch('startDate') && form.watch('endDate') && ( | |
| <div className="mt-2 flex items-center gap-2 p-2 rounded-md bg-indigo-50 dark:bg-indigo-950/30 border border-indigo-100 dark:border-indigo-900/50"> | |
| <CalendarDays className="h-4 w-4 text-indigo-600 dark:text-indigo-400" /> | |
| <span className="text-sm font-medium text-indigo-700 dark:text-indigo-300"> | |
| Total Working Days: <span className="text-lg ml-1 font-bold">{calculatedWorkingDays}</span> | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Day Type Selection - Half Day or Full Day - Only show for Comp Off requests */} | |
| {defaultCompOff && ( | |
| <div className="col-span-1 md:col-span-2"> | |
| <FormField | |
| control={form.control} | |
| name="dayType" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Day Type</FormLabel> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div | |
| className={cn( | |
| "border-2 rounded-lg p-4 cursor-pointer transition-all", | |
| field.value === 'half' | |
| ? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950/30" | |
| : "border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600" | |
| )} | |
| onClick={() => { | |
| field.onChange('half'); | |
| // Set end date to start date for half day | |
| const startDate = form.getValues('startDate'); | |
| form.setValue('endDate', startDate); | |
| }} | |
| > | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="font-semibold text-lg">Half Day</span> | |
| {field.value === 'half' && ( | |
| <Check className="h-5 w-5 text-indigo-600" /> | |
| )} | |
| </div> | |
| <p className="text-sm text-gray-600 dark:text-gray-400">0.5 working days</p> | |
| </div> | |
| <div | |
| className={cn( | |
| "border-2 rounded-lg p-4 cursor-pointer transition-all", | |
| field.value === 'full' | |
| ? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950/30" | |
| : "border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600" | |
| )} | |
| onClick={() => field.onChange('full')} | |
| > | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="font-semibold text-lg">Full Day</span> | |
| {field.value === 'full' && ( | |
| <Check className="h-5 w-5 text-indigo-600" /> | |
| )} | |
| </div> | |
| <p className="text-sm text-gray-600 dark:text-gray-400">1 working day</p> | |
| </div> | |
| </div> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| )} | |
| {/* Leave Type Field */} | |
| {!defaultCompOff && !hideLeaveType && ( | |
| <FormField | |
| control={form.control} | |
| name="type" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Leave Type</FormLabel> | |
| <div ref={typeDropdownRef} className="relative"> | |
| <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", | |
| showTypeDropdown && "ring-2 ring-ring ring-offset-2", | |
| isLoadingMasters && "opacity-70 cursor-not-allowed" | |
| )} | |
| onClick={() => !isLoadingMasters && setShowTypeDropdown(!showTypeDropdown)} | |
| > | |
| {isLoadingMasters ? ( | |
| <span className="flex items-center gap-2"> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| Loading leave types... | |
| </span> | |
| ) : field.value ? ( | |
| <div className="flex items-center gap-2"> | |
| <Badge className={getLeaveTypeColor(field.value)}> | |
| <span className="flex items-center gap-1"> | |
| {getLeaveTypeIcon(field.value)} | |
| <span>{field.value}</span> | |
| </span> | |
| </Badge> | |
| </div> | |
| ) : ( | |
| <span className="text-muted-foreground">Select leave type</span> | |
| )} | |
| <ChevronDown className="h-4 w-4 shrink-0 opacity-50" /> | |
| </div> | |
| {showTypeDropdown && !isLoadingMasters && ( | |
| <div className="absolute z-[90] mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| {(leaveMasters.length > 0 ? | |
| leaveMasters.map(master => master.leaveType).filter(type => type.toLowerCase() !== "work from home" && type.toLowerCase() !== "unpaid" && type.toLowerCase() !== "compensatory") : | |
| fallbackLeaveTypes.filter(type => type.toLowerCase() !== "work from home" && type.toLowerCase() !== "unpaid" && type.toLowerCase() !== "compensatory") | |
| ).map((type) => ( | |
| <div | |
| key={type} | |
| className={cn( | |
| "flex cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 hover:bg-accent hover:text-accent-foreground", | |
| field.value === type && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={() => { | |
| field.onChange(type); | |
| setShowTypeDropdown(false); | |
| // When type changes, try to find corresponding master ID | |
| const master = leaveMasters.find(m => | |
| m.leaveType.toLowerCase() === type.toLowerCase() | |
| ); | |
| if (master) { | |
| form.setValue('leaveMasterId', master.id); | |
| // For Half Day leave type, set end date equal to start date | |
| if (master.leaveType.toLowerCase() === "half day") { | |
| const startDate = form.getValues("startDate"); | |
| form.setValue("endDate", startDate); | |
| } | |
| } | |
| }} | |
| > | |
| <div className="flex items-center gap-2"> | |
| <Badge className={getLeaveTypeColor(type)}> | |
| <span className="flex items-center gap-1"> | |
| {getLeaveTypeIcon(type)} | |
| <span>{type}</span> | |
| </span> | |
| </Badge> | |
| </div> | |
| {field.value === type && <Check className="h-4 w-4" />} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| )} | |
| {/* Half Day Options - Show only when "Half Day" is selected */} | |
| {leaveMasters.length > 0 && form.watch('leaveMasterId') && | |
| (() => { | |
| const selectedMaster = leaveMasters.find(m => m.id === form.watch('leaveMasterId')); | |
| return selectedMaster && selectedMaster.leaveType.toLowerCase() === "half day"; | |
| })() && ( | |
| <div className="col-span-1 md:col-span-2"> | |
| <div className="rounded-md border p-4 bg-slate-50"> | |
| <h3 className="font-medium text-sm mb-3">Half Day Options</h3> | |
| <div className="space-y-4"> | |
| {/* Time Slot Selection */} | |
| <div className="space-y-2"> | |
| <label className="text-sm font-medium"> | |
| Time Slot | |
| </label> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div | |
| className={cn( | |
| "border rounded-md p-3 cursor-pointer transition-colors", | |
| form.watch('halfDayPortion') === 'first' | |
| ? "bg-primary text-primary-foreground border-primary" | |
| : "bg-card hover:bg-accent" | |
| )} | |
| onClick={() => form.setValue('halfDayPortion', 'first')} | |
| > | |
| <div className="flex items-center space-x-2"> | |
| <Sun className="h-4 w-4" /> | |
| <div> | |
| <p className="text-sm font-medium">First Half</p> | |
| <p className="text-xs opacity-90">Morning Session</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| className={cn( | |
| "border rounded-md p-3 cursor-pointer transition-colors", | |
| form.watch('halfDayPortion') === 'second' | |
| ? "bg-primary text-primary-foreground border-primary" | |
| : "bg-card hover:bg-accent" | |
| )} | |
| onClick={() => form.setValue('halfDayPortion', 'second')} | |
| > | |
| <div className="flex items-center space-x-2"> | |
| <Moon className="h-4 w-4" /> | |
| <div> | |
| <p className="text-sm font-medium">Second Half</p> | |
| <p className="text-xs opacity-90">Afternoon Session</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Leave Policy Details */} | |
| {!defaultCompOff && !hideLeaveType && leaveMasters.length > 0 && form.watch('leaveMasterId') && ( | |
| <div className="col-span-1 md:col-span-2"> | |
| <div className="rounded-md border p-4 bg-muted/30"> | |
| <h3 className="font-medium text-sm mb-2">Leave Policy Details</h3> | |
| {(() => { | |
| const selectedMaster = leaveMasters.find(m => m.id === form.watch('leaveMasterId')); | |
| if (!selectedMaster) return null; | |
| const isHalfDayLeave = selectedMaster.leaveType.toLowerCase() === "half day"; | |
| return ( | |
| <div className="space-y-3 text-sm"> | |
| {/* Show special information for Half Day leave */} | |
| {isHalfDayLeave && ( | |
| <div className="flex items-center text-emerald-600 bg-emerald-50 p-2 rounded"> | |
| <Clock className="h-4 w-4 mr-2" /> | |
| <span>This is a half day leave request ({form.watch('halfDayPortion') === 'first' ? 'Morning' : 'Afternoon'} session)</span> | |
| </div> | |
| )} | |
| {/* Available Days */} | |
| {/* Description */} | |
| {selectedMaster.description && ( | |
| <div> | |
| <span className="font-medium">Description: </span> | |
| <span>{selectedMaster.description}</span> | |
| </div> | |
| )} | |
| {/* Documentation required */} | |
| {selectedMaster.requiresDocumentation && ( | |
| <div className="flex items-center text-amber-600"> | |
| <FileText className="h-4 w-4 mr-1" /> | |
| <span>Documentation required</span> | |
| </div> | |
| )} | |
| {/* Advance notice required */} | |
| {selectedMaster.minDaysBeforeApply > 0 && ( | |
| <div className="flex items-center text-blue-600"> | |
| <span>Must apply {selectedMaster.minDaysBeforeApply} days in advance</span> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Reason Field */} | |
| <FormField | |
| control={form.control} | |
| name="reason" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>{defaultCompOff ? "Comp Off Reason / Project Details*" : hideLeaveType ? "Reason for Work From Home*" : "Reason for Leave*"}</FormLabel> | |
| <FormControl> | |
| <Textarea | |
| placeholder={defaultCompOff | |
| ? "Please provide details about your compensatory request (e.g. Project Name, dates worked, etc.)" | |
| : hideLeaveType | |
| ? "Please provide details about your work from home request" | |
| : "Please provide details about your leave request"} | |
| className="resize-none min-h-[100px]" | |
| {...field} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| {/* File Upload Field */} | |
| {/* Leave Balance Information */} | |
| {leaveMasters.length > 0 && form.watch('leaveMasterId') && ( | |
| <div className="border border-slate-200 rounded-md p-4 bg-slate-50"> | |
| {!defaultCompOff && <h3 className="font-medium text-sm mb-2">Leave Balance Information</h3>} | |
| {isLoadingBalances ? ( | |
| <div className="flex items-center gap-2 text-sm text-slate-500"> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| <span>Loading balance information...</span> | |
| </div> | |
| ) : ( | |
| <> | |
| {(() => { | |
| const selectedMaster = leaveMasters.find(m => m.id === form.watch('leaveMasterId')); | |
| const balance = selectedMaster ? getLeaveBalance(selectedMaster.id) : undefined; | |
| if (!selectedMaster) { | |
| return <p className="text-sm text-slate-500">Select a leave type to view balance</p>; | |
| } | |
| if (!balance) { | |
| return <p className="text-sm text-slate-500">No balance information available for this leave type</p>; | |
| } | |
| if (defaultCompOff) { | |
| return ( | |
| <div className="text-sm"> | |
| <p className="text-slate-500">Total Comp Off</p> | |
| <p className="font-medium text-lg text-orange-600"> | |
| {balance.pending} days | |
| </p> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="grid grid-cols-2 gap-3 text-sm"> | |
| <div> | |
| <p className="text-slate-500">Allocated</p> | |
| <p className="font-medium">{balance.allocated} days</p> | |
| </div> | |
| <div> | |
| <p className="text-slate-500">Used</p> | |
| <p className="font-medium">{balance.used} days</p> | |
| </div> | |
| <div> | |
| <p className="text-slate-500">Pending</p> | |
| <p className="font-medium">{balance.pending} days</p> | |
| </div> | |
| <div> | |
| <p className="text-slate-500">Carry Forward</p> | |
| <p className="font-medium">{balance.carryForward} days</p> | |
| </div> | |
| <div className="col-span-2"> | |
| <p className="text-slate-500">Available Balance</p> | |
| <p className="font-medium text-lg"> | |
| <Badge variant={balance.pending > 0 ? "default" : "destructive"}> | |
| {balance.pending} days | |
| </Badge> | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| })()} | |
| </> | |
| )} | |
| </div> | |
| )} | |
| <div className="absolute bottom-0 left-0 right-0 w-full bg-background border-t px-6 py-4 flex justify-end gap-2 z-10"> | |
| <Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}> | |
| Cancel | |
| </Button> | |
| <Button type="submit" disabled={isSubmitting || isLoadingMasters}> | |
| {isSubmitting ? ( | |
| <> | |
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| Submitting... | |
| </> | |
| ) : "Submit Request"} | |
| </Button> | |
| </div> | |
| </form> | |
| </Form> | |
| ); | |
| }; | |
| export default LeaveForm; |