Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef } from "react"; | |
| import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; | |
| import { format, parseISO, isBefore, isAfter } from "date-fns"; | |
| import { | |
| Calendar as CalendarIcon, | |
| ChevronDown, | |
| ChevronUp, | |
| Edit, | |
| Loader2, | |
| MoreHorizontal, | |
| Plus, | |
| Trash2, | |
| Gift, | |
| FilterX, | |
| Search, | |
| CalendarDays, | |
| CalendarPlus, | |
| X | |
| } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Calendar } from "@/components/ui/calendar"; | |
| import { | |
| Card, | |
| CardContent, | |
| CardDescription, | |
| CardHeader, | |
| CardTitle, | |
| CardFooter, | |
| } from "@/components/ui/card"; | |
| import { | |
| Form, | |
| FormControl, | |
| FormDescription, | |
| FormField, | |
| FormItem, | |
| FormLabel, | |
| FormMessage, | |
| } from "@/components/ui/form"; | |
| import { Input } from "@/components/ui/input"; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| } from "@/components/ui/table"; | |
| import { Checkbox } from "@/components/ui/checkbox"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { toast } from "sonner"; | |
| import { holidayApi } from "@/services/holidayApi"; | |
| import { Holiday, HolidayCreateRequest, HolidayUpdateRequest } from "@/types"; | |
| import { useForm } from "react-hook-form"; | |
| import { zodResolver } from "@hookform/resolvers/zod"; | |
| import * as z from "zod"; | |
| import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; | |
| import { cn } from "@/lib/utils"; | |
| import Layout from "@/components/layout/layout"; | |
| import CustomDialog from "@/components/CustomDialog"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
| import { ScrollArea } from "@/components/ui/scroll-area"; | |
| import { useMediaQuery } from "@/hooks/use-media-query"; | |
| import { useNavigate } from "react-router-dom"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| // Form validation schema | |
| const holidaySchema = z.object({ | |
| id: z.number().optional(), | |
| name: z.string().min(2, { message: "Name must be at least 2 characters" }), | |
| date: z.date(), | |
| description: z.string().min(3, { message: "Description must be at least 3 characters" }), | |
| isRecurring: z.boolean().default(false), | |
| appliesTo: z.string() | |
| }); | |
| type HolidayFormValues = z.infer<typeof holidaySchema>; | |
| // Helper function to get seasonal color based on date | |
| const getSeasonalColor = (date: Date): string => { | |
| 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-950 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-950 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-950 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-950 dark:border-orange-800 dark:text-orange-300"; | |
| }; | |
| // Add the custom filter options array | |
| const filterOptions = [ | |
| { value: "all", label: "All Holidays" }, | |
| { value: "upcoming", label: "Upcoming" }, | |
| { value: "past", label: "Past" }, | |
| { value: "recurring", label: "Recurring" } | |
| ]; | |
| // Custom Year Select Component | |
| function CustomYearSelect({ | |
| selectedYear, | |
| availableYears, | |
| handleYearChange | |
| }) { | |
| 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]); | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| const displayYear = selectedYear === "all" ? "All Years" : selectedYear.toString(); | |
| const currentYear = new Date().getFullYear(); | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <Button | |
| variant="outline" | |
| className="w-[120px] h-10 justify-between bg-white dark:bg-gray-950" | |
| onClick={handleButtonClick} | |
| > | |
| <div className="flex items-center"> | |
| <CalendarDays className="h-4 w-4 mr-2 text-blue-500" /> | |
| <span>{displayYear}</span> | |
| </div> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </Button> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 w-[140px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| <Button | |
| variant="ghost" | |
| className={cn( | |
| "w-full justify-start rounded-sm font-normal hover:bg-blue-50 dark:hover:bg-blue-900/20", | |
| selectedYear === "all" && "bg-blue-50 dark:bg-blue-900/20 font-medium" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleYearChange("all"); | |
| setOpen(false); | |
| }} | |
| > | |
| All Years | |
| </Button> | |
| {availableYears.map((year) => ( | |
| <Button | |
| key={year} | |
| variant="ghost" | |
| className={cn( | |
| "w-full justify-start rounded-sm font-normal hover:bg-blue-50 dark:hover:bg-blue-900/20", | |
| selectedYear === year && "bg-blue-50 dark:bg-blue-900/20 font-medium" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleYearChange(year); | |
| setOpen(false); | |
| }} | |
| > | |
| <span>{year}</span> | |
| {year === currentYear && ( | |
| <span className="ml-2 text-xs text-blue-600 dark:text-blue-400">(Current)</span> | |
| )} | |
| </Button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Add the custom filter component before the Holidays component | |
| function CustomFilterDropdown({ | |
| filterType, | |
| handleFilterChange, | |
| resetFilters, | |
| hasFilters | |
| }) { | |
| const [open, setOpen] = useState(false); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| // Handle outside clicks to close the dropdown | |
| 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 handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| const selectedOption = filterOptions.find(option => option.value === filterType) || filterOptions[0]; | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <Button | |
| variant="outline" | |
| className="w-[130px] h-10 justify-between bg-white dark:bg-gray-950" | |
| onClick={handleButtonClick} | |
| > | |
| <div className="flex items-center"> | |
| <CalendarIcon className="h-4 w-4 mr-2 text-amber-500" /> | |
| <span>{selectedOption.label}</span> | |
| </div> | |
| <div className="flex items-center"> | |
| {hasFilters && ( | |
| <X | |
| className="h-4 w-4 mr-1 hover:text-red-500" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| resetFilters(); | |
| setOpen(false); | |
| }} | |
| /> | |
| )} | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </div> | |
| </Button> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 w-[180px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| {filterOptions.map((option) => ( | |
| <Button | |
| key={option.value} | |
| variant="ghost" | |
| className={cn( | |
| "w-full justify-start rounded-sm font-normal hover:bg-amber-50 dark:hover:bg-amber-900/20", | |
| filterType === option.value && "bg-amber-50 dark:bg-amber-900/20 font-medium" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleFilterChange(option.value as any); | |
| setOpen(false); | |
| }} | |
| > | |
| {option.label} | |
| </Button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const Holidays = () => { | |
| const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); | |
| const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); | |
| const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); | |
| const [selectedHoliday, setSelectedHoliday] = useState<Holiday | null>(null); | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [filterType, setFilterType] = useState<"all" | "upcoming" | "past" | "recurring">("upcoming"); | |
| const [selectedYear, setSelectedYear] = useState<number | "all">(new Date().getFullYear()); | |
| const [activeTab, setActiveTab] = useState("cards"); | |
| const queryClient = useQueryClient(); | |
| const isMobile = useMediaQuery("(max-width: 768px)"); | |
| const navigate = useNavigate(); | |
| const { userData } = useAuth(); | |
| // Check if user is admin (has permissions to add/edit/delete holidays) | |
| // In a real app, this would be based on user role or permissions | |
| const isAdmin = userData?.roleId === 1 || userData?.roleId === 2; // Assuming roleId 1 and 2 are admin roles | |
| // Fetch holidays | |
| const { | |
| data: holidays = [], | |
| isLoading, | |
| error, | |
| } = useQuery<Holiday[]>({ | |
| queryKey: ["holidays"], | |
| queryFn: () => holidayApi.getAll(), | |
| }); | |
| // Get unique years from holidays | |
| const availableYears = Array.from( | |
| new Set(holidays.map(h => new Date(h.date).getFullYear())) | |
| ).sort((a, b) => b - a); | |
| // Filter and search holidays | |
| const filteredHolidays = holidays.filter(holiday => { | |
| // Apply search filter | |
| const matchesSearch = searchQuery === "" || | |
| holiday.name.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| holiday.description.toLowerCase().includes(searchQuery.toLowerCase()); | |
| // Apply type filter | |
| let matchesType = true; | |
| const today = new Date(); | |
| if (filterType === "upcoming") { | |
| matchesType = isAfter(parseISO(holiday.date), today); | |
| } else if (filterType === "past") { | |
| matchesType = isBefore(parseISO(holiday.date), today); | |
| } else if (filterType === "recurring") { | |
| matchesType = holiday.isRecurring; | |
| } | |
| // Apply year filter | |
| const matchesYear = selectedYear === "all" || | |
| new Date(holiday.date).getFullYear() === selectedYear; | |
| return matchesSearch && matchesType && matchesYear; | |
| }); | |
| // Create form | |
| const createForm = useForm<HolidayFormValues>({ | |
| resolver: zodResolver(holidaySchema), | |
| defaultValues: { | |
| name: "", | |
| date: new Date(), | |
| description: "", | |
| isRecurring: false, | |
| appliesTo: "All" | |
| }, | |
| }); | |
| // Edit form | |
| const editForm = useForm<HolidayFormValues>({ | |
| resolver: zodResolver(holidaySchema), | |
| defaultValues: { | |
| id: 0, | |
| name: "", | |
| date: new Date(), | |
| description: "", | |
| isRecurring: false, | |
| appliesTo: "All" | |
| }, | |
| }); | |
| // Reset create form when dialog opens/closes | |
| useEffect(() => { | |
| if (!isCreateDialogOpen) { | |
| setTimeout(() => { | |
| createForm.reset({ | |
| name: "", | |
| date: new Date(), | |
| description: "", | |
| isRecurring: false, | |
| appliesTo: "All" | |
| }); | |
| }, 100); | |
| } | |
| }, [isCreateDialogOpen, createForm]); | |
| // Set edit form values when a holiday is selected for editing | |
| useEffect(() => { | |
| if (selectedHoliday && isEditDialogOpen) { | |
| editForm.reset({ | |
| id: selectedHoliday.id, | |
| name: selectedHoliday.name, | |
| date: parseISO(selectedHoliday.date), | |
| description: selectedHoliday.description, | |
| isRecurring: selectedHoliday.isRecurring, | |
| appliesTo: selectedHoliday.appliesTo | |
| }); | |
| } | |
| }, [selectedHoliday, isEditDialogOpen, editForm]); | |
| // Create mutation | |
| const createMutation = useMutation({ | |
| mutationFn: (data: HolidayCreateRequest) => holidayApi.create(data), | |
| onSuccess: () => { | |
| toast.success("Holiday created successfully"); | |
| setIsCreateDialogOpen(false); | |
| queryClient.invalidateQueries({ queryKey: ["holidays"] }); | |
| }, | |
| onError: (error) => { | |
| toast.error("Failed to create holiday"); | |
| console.error("Error creating holiday:", error); | |
| }, | |
| }); | |
| // Update mutation | |
| const updateMutation = useMutation({ | |
| mutationFn: (data: HolidayUpdateRequest) => | |
| holidayApi.update(data.id, data), | |
| onSuccess: () => { | |
| toast.success("Holiday updated successfully"); | |
| setIsEditDialogOpen(false); | |
| queryClient.invalidateQueries({ queryKey: ["holidays"] }); | |
| }, | |
| onError: (error) => { | |
| toast.error("Failed to update holiday"); | |
| console.error("Error updating holiday:", error); | |
| }, | |
| }); | |
| // Delete mutation | |
| const deleteMutation = useMutation({ | |
| mutationFn: (id: number) => holidayApi.delete(id), | |
| onSuccess: () => { | |
| toast.success("Holiday deleted successfully"); | |
| setIsDeleteDialogOpen(false); | |
| queryClient.invalidateQueries({ queryKey: ["holidays"] }); | |
| }, | |
| onError: (error) => { | |
| toast.error("Failed to delete holiday"); | |
| console.error("Error deleting holiday:", error); | |
| }, | |
| }); | |
| // Handle create form submission | |
| const onCreateSubmit = (values: HolidayFormValues) => { | |
| createMutation.mutate({ | |
| name: values.name, | |
| date: values.date.toISOString(), | |
| description: values.description, | |
| isRecurring: values.isRecurring, | |
| appliesTo: values.appliesTo | |
| }); | |
| }; | |
| // Handle edit form submission | |
| const onEditSubmit = (values: HolidayFormValues) => { | |
| if (!values.id) return; | |
| updateMutation.mutate({ | |
| id: values.id, | |
| name: values.name, | |
| date: values.date.toISOString(), | |
| description: values.description, | |
| isRecurring: values.isRecurring, | |
| appliesTo: values.appliesTo | |
| }); | |
| }; | |
| // Handle delete confirmation | |
| const onDeleteConfirm = () => { | |
| if (selectedHoliday) { | |
| deleteMutation.mutate(selectedHoliday.id); | |
| } | |
| }; | |
| // Open edit dialog with selected holiday | |
| const handleEdit = (holiday: Holiday) => { | |
| setSelectedHoliday(holiday); | |
| setIsEditDialogOpen(true); | |
| }; | |
| // Open delete dialog with selected holiday | |
| const handleDelete = (holiday: Holiday) => { | |
| setSelectedHoliday(holiday); | |
| setIsDeleteDialogOpen(true); | |
| }; | |
| // Handler for filter change | |
| const handleFilterChange = (type: "all" | "upcoming" | "past" | "recurring") => { | |
| setFilterType(type); | |
| }; | |
| // Handler for year change | |
| const handleYearChange = (year: number | "all") => { | |
| setSelectedYear(year); | |
| }; | |
| // Reset filters | |
| const resetFilters = () => { | |
| setSearchQuery(""); | |
| setFilterType("all"); | |
| setSelectedYear("all"); | |
| }; | |
| if (error) { | |
| return ( | |
| <Layout> | |
| <div className="flex h-[450px] w-full items-center justify-center p-4"> | |
| <Card className="w-full max-w-md border-red-200"> | |
| <CardHeader className="bg-red-50 dark:bg-red-900/20"> | |
| <div className="flex items-center gap-2"> | |
| <div className="rounded-full bg-red-100 p-2 dark:bg-red-900/30"> | |
| <FilterX className="h-5 w-5 text-red-600 dark:text-red-400" /> | |
| </div> | |
| <CardTitle className="text-red-700 dark:text-red-400">Error Loading Holidays</CardTitle> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="pt-6"> | |
| <p className="text-muted-foreground"> | |
| An unexpected error occurred. Please try again later or contact support if the problem persists. | |
| </p> | |
| </CardContent> | |
| <CardFooter> | |
| <Button variant="outline" className="w-full" onClick={() => window.location.reload()}> | |
| Reload Page | |
| </Button> | |
| </CardFooter> | |
| </Card> | |
| </div> | |
| </Layout> | |
| ); | |
| } | |
| return ( | |
| <Layout> | |
| <div className="container p-4 md:p-8"> | |
| <div className="mb-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm p-4"> | |
| <div className="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-amber-600 dark:text-amber-400"> | |
| <Gift className="h-7 w-7" /> | |
| <span>Company Holidays</span> | |
| </h1> | |
| <p className="text-gray-500 dark:text-gray-400 mt-1"> | |
| View and manage company-wide holiday schedule | |
| </p> | |
| </div> | |
| <Button | |
| className="flex items-center gap-2 bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700 text-white shadow-md" | |
| onClick={() => navigate('/leaves')} | |
| > | |
| <CalendarPlus className="h-4 w-4" /> | |
| <span>Request Leave</span> | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> | |
| <div className="lg:col-span-3"> | |
| <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-amber-600 dark:text-amber-400"> | |
| <CalendarIcon className="h-4 w-4" /> | |
| <span>Holiday Calendar</span> | |
| </CardTitle> | |
| <div className="flex items-center gap-2"> | |
| <div className="relative"> | |
| <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search holidays..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="pl-8 max-w-[200px] bg-white dark:bg-gray-950" | |
| /> | |
| </div> | |
| <CustomYearSelect | |
| selectedYear={selectedYear} | |
| availableYears={availableYears} | |
| handleYearChange={handleYearChange} | |
| /> | |
| <CustomFilterDropdown | |
| filterType={filterType} | |
| handleFilterChange={handleFilterChange} | |
| resetFilters={resetFilters} | |
| hasFilters={searchQuery || filterType !== "upcoming" || selectedYear !== "all"} | |
| /> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="pb-4 pt-2"> | |
| {isLoading ? ( | |
| <div className="flex justify-center items-center py-32"> | |
| <Loader2 className="h-8 w-8 animate-spin text-amber-500" /> | |
| </div> | |
| ) : error ? ( | |
| <div className="text-center py-32 border rounded-lg bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800"> | |
| <p className="text-red-600 dark:text-red-400">Failed to load holiday data. Please try again.</p> | |
| </div> | |
| ) : filteredHolidays.length === 0 ? ( | |
| <div className="text-center py-32 border rounded-lg bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700"> | |
| <Gift 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 holidays found</h3> | |
| <p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto mt-1"> | |
| Try adjusting your filters or add new holidays if you have permission. | |
| </p> | |
| </div> | |
| ) : ( | |
| <Tabs value={activeTab} onValueChange={setActiveTab}> | |
| <TabsList className="grid grid-cols-2 max-w-[200px] mb-6"> | |
| <TabsTrigger value="cards">Cards</TabsTrigger> | |
| <TabsTrigger value="table">Table</TabsTrigger> | |
| </TabsList> | |
| <div className="flex justify-between items-center mb-4"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => navigate('/leaves')} | |
| className="flex items-center gap-2 text-indigo-600 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800 hover:bg-indigo-50 dark:hover:bg-indigo-900/30" | |
| > | |
| <CalendarPlus className="h-4 w-4" /> | |
| <span>Back to Leaves</span> | |
| </Button> | |
| <div className="flex items-center gap-2"> | |
| {isAdmin && ( | |
| <Button | |
| onClick={() => setIsCreateDialogOpen(true)} | |
| 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" | |
| > | |
| <Plus className="h-4 w-4" /> | |
| <span>Add Holiday</span> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| <TabsContent value="cards" className="mt-0"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> | |
| {filteredHolidays.map((holiday) => { | |
| const holidayDate = parseISO(holiday.date); | |
| const colorClass = getSeasonalColor(holidayDate); | |
| return ( | |
| <div | |
| key={holiday.id} | |
| className={`p-4 rounded-lg border ${colorClass} transition-all hover:shadow-md`} | |
| > | |
| <div className="flex justify-between items-start mb-3"> | |
| <div> | |
| <h3 className="font-semibold text-lg">{holiday.name}</h3> | |
| <p className="text-gray-500 dark:text-gray-400 text-sm"> | |
| {format(holidayDate, "EEEE, MMMM d, yyyy")} | |
| </p> | |
| </div> | |
| {holiday.isRecurring && ( | |
| <Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 hover:bg-purple-200"> | |
| Recurring | |
| </Badge> | |
| )} | |
| </div> | |
| <div className="mt-3"> | |
| <p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2"> | |
| {holiday.description} | |
| </p> | |
| </div> | |
| <div className="flex justify-between items-center mt-4"> | |
| <span className="text-sm text-gray-500 dark:text-gray-400"> | |
| {holiday.appliesTo} | |
| </span> | |
| {isAdmin && ( | |
| <div className="flex gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleEdit(holiday)} | |
| className="h-8 w-8 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30" | |
| > | |
| <Edit className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleDelete(holiday)} | |
| className="h-8 w-8 p-0 text-red-600 hover:text-red-800 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/30" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </TabsContent> | |
| <TabsContent value="table" className="mt-0"> | |
| <div className="rounded-md border overflow-hidden"> | |
| <Table> | |
| <TableHeader className="bg-gray-50 dark:bg-gray-800"> | |
| <TableRow> | |
| <TableHead>Name</TableHead> | |
| <TableHead>Date</TableHead> | |
| <TableHead>Description</TableHead> | |
| <TableHead>Recurring</TableHead> | |
| <TableHead>Applies To</TableHead> | |
| {isAdmin && <TableHead className="w-[100px] text-right">Actions</TableHead>} | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {filteredHolidays.map((holiday) => ( | |
| <TableRow key={holiday.id}> | |
| <TableCell className="font-medium">{holiday.name}</TableCell> | |
| <TableCell>{format(parseISO(holiday.date), "MMM d, yyyy")}</TableCell> | |
| <TableCell className="max-w-[300px] truncate">{holiday.description}</TableCell> | |
| <TableCell>{holiday.isRecurring ? "Yes" : "No"}</TableCell> | |
| <TableCell>{holiday.appliesTo}</TableCell> | |
| {isAdmin && ( | |
| <TableCell className="text-right"> | |
| <div className="flex justify-end gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleEdit(holiday)} | |
| className="h-8 w-8 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-50" | |
| > | |
| <Edit className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleDelete(holiday)} | |
| className="h-8 w-8 p-0 text-red-600 hover:text-red-800 hover:bg-red-50" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| </TableCell> | |
| )} | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| </TabsContent> | |
| </Tabs> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </div> | |
| <div className="space-y-6"> | |
| <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"> | |
| <CalendarIcon className="h-5 w-5" /> | |
| <span>Holiday Stats {selectedYear !== "all" && `(${selectedYear})`}</span> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="pt-2"> | |
| <div className="space-y-4"> | |
| <div className="flex justify-between items-center"> | |
| <span>Total Holidays</span> | |
| <span className="font-bold text-amber-600 dark:text-amber-400">{filteredHolidays.length}</span> | |
| </div> | |
| <div className="flex justify-between items-center"> | |
| <span>Upcoming Holidays</span> | |
| <span className="font-bold text-green-600 dark:text-green-400"> | |
| {filteredHolidays.filter(h => isAfter(parseISO(h.date), new Date())).length} | |
| </span> | |
| </div> | |
| <div className="flex justify-between items-center"> | |
| <span>Recurring Holidays</span> | |
| <span className="font-bold text-blue-600 dark:text-blue-400"> | |
| {filteredHolidays.filter(h => h.isRecurring).length} | |
| </span> | |
| </div> | |
| <Button | |
| variant="outline" | |
| className="w-full mt-2 justify-center items-center 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('/leaves')} | |
| > | |
| <CalendarPlus className="mr-2 h-4 w-4" /> | |
| <span>Request Leave</span> | |
| </Button> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Create Dialog */} | |
| <CustomDialog | |
| isOpen={isCreateDialogOpen} | |
| onClose={() => !createMutation.isPending && setIsCreateDialogOpen(false)} | |
| title="Add New Holiday" | |
| description="Create a new holiday or important date for the organization" | |
| allowClose={!createMutation.isPending} | |
| isPending={createMutation.isPending} | |
| > | |
| <Form {...createForm}> | |
| <form onSubmit={createForm.handleSubmit(onCreateSubmit)} className="space-y-4"> | |
| <FormField | |
| control={createForm.control} | |
| name="name" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Holiday Name</FormLabel> | |
| <FormControl> | |
| <Input placeholder="e.g. Independence Day" {...field} /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={createForm.control} | |
| name="date" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Date</FormLabel> | |
| <FormControl> | |
| <Input | |
| type="date" | |
| value={field.value ? format(field.value, "yyyy-MM-dd") : ""} | |
| onChange={(e) => { | |
| if (e.target.value) { | |
| field.onChange(new Date(e.target.value)); | |
| } | |
| }} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={createForm.control} | |
| name="description" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Description</FormLabel> | |
| <FormControl> | |
| <Textarea | |
| placeholder="Provide a brief description of this holiday" | |
| className="min-h-[80px]" | |
| {...field} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <FormField | |
| control={createForm.control} | |
| name="isRecurring" | |
| render={({ field }) => ( | |
| <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> | |
| <FormControl> | |
| <Checkbox | |
| checked={field.value} | |
| onCheckedChange={field.onChange} | |
| /> | |
| </FormControl> | |
| <div className="space-y-1 leading-none"> | |
| <FormLabel>Recurring Holiday</FormLabel> | |
| <FormDescription> | |
| This holiday repeats annually | |
| </FormDescription> | |
| </div> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={createForm.control} | |
| name="appliesTo" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Applies To</FormLabel> | |
| <FormControl> | |
| <select | |
| className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" | |
| value={field.value} | |
| onChange={(e) => field.onChange(e.target.value)} | |
| > | |
| <option value="All">All Employees</option> | |
| <option value="Department">Specific Department</option> | |
| <option value="Location">Specific Location</option> | |
| </select> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| <div className="flex justify-end gap-2 mt-4"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| onClick={() => !createMutation.isPending && setIsCreateDialogOpen(false)} | |
| > | |
| Cancel | |
| </Button> | |
| <Button type="submit" disabled={createMutation.isPending}> | |
| {createMutation.isPending && ( | |
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| )} | |
| Create Holiday | |
| </Button> | |
| </div> | |
| </form> | |
| </Form> | |
| </CustomDialog> | |
| {/* Edit Dialog */} | |
| <CustomDialog | |
| isOpen={isEditDialogOpen} | |
| onClose={() => !updateMutation.isPending && setIsEditDialogOpen(false)} | |
| title="Edit Holiday" | |
| description="Update the details of the selected holiday" | |
| allowClose={!updateMutation.isPending} | |
| isPending={updateMutation.isPending} | |
| > | |
| <Form {...editForm}> | |
| <form onSubmit={editForm.handleSubmit(onEditSubmit)} className="space-y-4"> | |
| <FormField | |
| control={editForm.control} | |
| name="name" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Holiday Name</FormLabel> | |
| <FormControl> | |
| <Input placeholder="e.g. Independence Day" {...field} /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={editForm.control} | |
| name="date" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Date</FormLabel> | |
| <FormControl> | |
| <Input | |
| type="date" | |
| value={field.value ? format(field.value, "yyyy-MM-dd") : ""} | |
| onChange={(e) => { | |
| if (e.target.value) { | |
| field.onChange(new Date(e.target.value)); | |
| } | |
| }} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={editForm.control} | |
| name="description" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Description</FormLabel> | |
| <FormControl> | |
| <Textarea | |
| placeholder="Provide a brief description of this holiday" | |
| className="min-h-[80px]" | |
| {...field} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <FormField | |
| control={editForm.control} | |
| name="isRecurring" | |
| render={({ field }) => ( | |
| <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> | |
| <FormControl> | |
| <Checkbox | |
| checked={field.value} | |
| onCheckedChange={field.onChange} | |
| /> | |
| </FormControl> | |
| <div className="space-y-1 leading-none"> | |
| <FormLabel>Recurring Holiday</FormLabel> | |
| <FormDescription> | |
| This holiday repeats annually | |
| </FormDescription> | |
| </div> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={editForm.control} | |
| name="appliesTo" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Applies To</FormLabel> | |
| <FormControl> | |
| <select | |
| className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" | |
| value={field.value} | |
| onChange={(e) => field.onChange(e.target.value)} | |
| > | |
| <option value="All">All Employees</option> | |
| <option value="Department">Specific Department</option> | |
| <option value="Location">Specific Location</option> | |
| </select> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| <div className="flex justify-end gap-2 mt-4"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| onClick={() => !updateMutation.isPending && setIsEditDialogOpen(false)} | |
| > | |
| Cancel | |
| </Button> | |
| <Button type="submit" disabled={updateMutation.isPending}> | |
| {updateMutation.isPending && ( | |
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| )} | |
| Update Holiday | |
| </Button> | |
| </div> | |
| </form> | |
| </Form> | |
| </CustomDialog> | |
| {/* Delete Confirmation Dialog */} | |
| <CustomDialog | |
| isOpen={isDeleteDialogOpen} | |
| onClose={() => !deleteMutation.isPending && setIsDeleteDialogOpen(false)} | |
| title="Delete Holiday" | |
| description="Are you sure you want to delete this holiday? This action cannot be undone." | |
| allowClose={!deleteMutation.isPending} | |
| isPending={deleteMutation.isPending} | |
| > | |
| {selectedHoliday && ( | |
| <div className="mt-4 space-y-4"> | |
| <div className="rounded-lg border border-muted p-3"> | |
| <div className="font-medium">{selectedHoliday.name}</div> | |
| <div className="mt-1 flex flex-wrap gap-2"> | |
| <Badge variant="outline" className={cn(getSeasonalColor(parseISO(selectedHoliday.date)))}> | |
| {format(parseISO(selectedHoliday.date), "MMMM d, yyyy")} | |
| {selectedHoliday.isRecurring && <span className="ml-1">(Recurring)</span>} | |
| </Badge> | |
| </div> | |
| <p className="mt-2 text-sm text-muted-foreground">{selectedHoliday.description}</p> | |
| </div> | |
| </div> | |
| )} | |
| <div className="flex justify-end gap-2 mt-6"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| onClick={() => !deleteMutation.isPending && setIsDeleteDialogOpen(false)} | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| type="button" | |
| variant="destructive" | |
| onClick={onDeleteConfirm} | |
| disabled={deleteMutation.isPending} | |
| > | |
| {deleteMutation.isPending && ( | |
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| )} | |
| Delete Holiday | |
| </Button> | |
| </div> | |
| </CustomDialog> | |
| </Layout> | |
| ); | |
| }; | |
| export default Holidays; |