Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef } from "react"; | |
| import { format } from "date-fns"; | |
| import { CalendarIcon, Check, Info, ChevronDown } from "lucide-react"; | |
| import { zodResolver } from "@hookform/resolvers/zod"; | |
| import { useForm } from "react-hook-form"; | |
| import * as z from "zod"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { | |
| Form, | |
| FormControl, | |
| FormField, | |
| FormItem, | |
| FormLabel, | |
| FormMessage, | |
| } from "@/components/ui/form"; | |
| import { cn } from "@/lib/utils"; | |
| import { AppraisalCycle, Appraisal } from "@/types"; | |
| import CustomDialog from "@/components/CustomDialog"; | |
| import { getEmployeesByRole } from "@/services/employeeApi"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; | |
| import { ScrollArea } from "@/components/ui/scroll-area"; | |
| const formSchema = z.object({ | |
| cycleId: z.string({ | |
| required_error: "Please select an appraisal cycle.", | |
| }), | |
| appraiserId: z.string({ | |
| required_error: "Please select a manager.", | |
| }), | |
| }); | |
| interface MissingCycleDialogProps { | |
| open: boolean; | |
| onOpenChange: (open: boolean) => void; | |
| onSubmit: (cycleId: number, appraiserId: number) => void; | |
| cycles: AppraisalCycle[]; | |
| currentAppraisals: Appraisal[]; | |
| isLoading?: boolean; | |
| } | |
| export function MissingCycleDialog({ | |
| open, | |
| onOpenChange, | |
| onSubmit, | |
| cycles, | |
| currentAppraisals, | |
| isLoading = false, | |
| }: MissingCycleDialogProps) { | |
| const [managers, setManagers] = useState<Array<{ id: number; name: string }>>([]); | |
| const [isLoadingManagers, setIsLoadingManagers] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [availableCycles, setAvailableCycles] = useState<AppraisalCycle[]>([]); | |
| const { userData } = useAuth(); | |
| // Dropdown state | |
| const [cycleDropdownOpen, setCycleDropdownOpen] = useState(false); | |
| const [managerDropdownOpen, setManagerDropdownOpen] = useState(false); | |
| // Search state | |
| const [cycleSearch, setCycleSearch] = useState(""); | |
| const [managerSearch, setManagerSearch] = useState(""); | |
| // Refs for click outside handling | |
| const cycleDropdownRef = useRef<HTMLDivElement>(null); | |
| const managerDropdownRef = useRef<HTMLDivElement>(null); | |
| const form = useForm<z.infer<typeof formSchema>>({ | |
| resolver: zodResolver(formSchema), | |
| defaultValues: { | |
| cycleId: "", | |
| appraiserId: "", | |
| }, | |
| }); | |
| // Handle click outside to close dropdowns | |
| useEffect(() => { | |
| function handleClickOutside(event: MouseEvent) { | |
| if (cycleDropdownRef.current && !cycleDropdownRef.current.contains(event.target as Node)) { | |
| setCycleDropdownOpen(false); | |
| } | |
| if (managerDropdownRef.current && !managerDropdownRef.current.contains(event.target as Node)) { | |
| setManagerDropdownOpen(false); | |
| } | |
| } | |
| document.addEventListener("mousedown", handleClickOutside); | |
| return () => { | |
| document.removeEventListener("mousedown", handleClickOutside); | |
| }; | |
| }, []); | |
| // Filter out cycles that the employee already has appraisals for | |
| useEffect(() => { | |
| if (cycles && currentAppraisals) { | |
| const currentCycleIds = currentAppraisals.map(a => a.cycleId); | |
| const filtered = cycles.filter(cycle => !currentCycleIds.includes(cycle.cycleId)); | |
| setAvailableCycles(filtered); | |
| } | |
| }, [cycles, currentAppraisals]); | |
| // Load managers when component mounts | |
| useEffect(() => { | |
| const loadManagers = async () => { | |
| setIsLoadingManagers(true); | |
| try { | |
| // Get employees with manager roles (assuming role IDs for managers are 1, 2, 3) | |
| // In a real app, you would need to filter by appropriate manager roles | |
| const managerRoleIds = [1, 2, 3]; // Example role IDs for managers | |
| const promises = managerRoleIds.map(roleId => getEmployeesByRole(roleId)); | |
| const responses = await Promise.all(promises); | |
| const allManagers: Array<{ id: number; name: string }> = []; | |
| responses.forEach(response => { | |
| if (response.success) { | |
| const managers = response.data.map(employee => ({ | |
| id: employee.id, | |
| name: `${employee.firstName} ${employee.lastName}` | |
| })); | |
| allManagers.push(...managers); | |
| } | |
| }); | |
| setManagers(allManagers); | |
| } catch (error) { | |
| console.error("Error loading managers:", error); | |
| setError("Failed to load managers. Please try again."); | |
| } finally { | |
| setIsLoadingManagers(false); | |
| } | |
| }; | |
| if (open) { | |
| loadManagers(); | |
| } | |
| }, [open]); | |
| function handleSubmit(values: z.infer<typeof formSchema>) { | |
| setError(null); | |
| const cycleId = parseInt(values.cycleId); | |
| const appraiserId = parseInt(values.appraiserId); | |
| if (isNaN(cycleId) || isNaN(appraiserId)) { | |
| setError("Invalid selection. Please try again."); | |
| return; | |
| } | |
| onSubmit(cycleId, appraiserId); | |
| } | |
| // Handle dialog closing | |
| const handleClose = () => { | |
| onOpenChange(false); | |
| form.reset(); | |
| setCycleSearch(""); | |
| setManagerSearch(""); | |
| setCycleDropdownOpen(false); | |
| setManagerDropdownOpen(false); | |
| }; | |
| const isPastCycle = (cycle: AppraisalCycle) => { | |
| const now = new Date(); | |
| const endDate = new Date(cycle.endDate); | |
| return now > endDate; | |
| }; | |
| // Find cycle name by id | |
| const getCycleName = (cycleId: string) => { | |
| if (!cycleId) return ""; | |
| const cycle = availableCycles.find(c => c.cycleId.toString() === cycleId); | |
| return cycle ? cycle.name : ""; | |
| }; | |
| // Find manager name by id | |
| const getManagerName = (managerId: string) => { | |
| if (!managerId) return ""; | |
| const manager = managers.find(m => m.id.toString() === managerId); | |
| return manager ? manager.name : ""; | |
| }; | |
| // Filter cycles based on search | |
| const filteredCycles = availableCycles.filter(cycle => | |
| cycle.name.toLowerCase().includes(cycleSearch.toLowerCase()) | |
| ); | |
| // Filter managers based on search | |
| const filteredManagers = managers.filter(manager => | |
| manager.name.toLowerCase().includes(managerSearch.toLowerCase()) | |
| ); | |
| return ( | |
| <CustomDialog | |
| isOpen={open} | |
| onClose={handleClose} | |
| title="Request Missing Appraisal Cycle" | |
| description="Request an appraisal for a cycle that you missed." | |
| allowClose={!isLoading} | |
| isPending={isLoading} | |
| > | |
| {availableCycles.length === 0 ? ( | |
| <Alert> | |
| <Info className="h-4 w-4" /> | |
| <AlertTitle>No missing cycles</AlertTitle> | |
| <AlertDescription> | |
| You have appraisals for all available cycles. | |
| </AlertDescription> | |
| </Alert> | |
| ) : ( | |
| <Form {...form}> | |
| <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6"> | |
| <FormField | |
| control={form.control} | |
| name="cycleId" | |
| render={({ field }) => ( | |
| <FormItem className="flex flex-col"> | |
| <FormLabel>Appraisal Cycle</FormLabel> | |
| <div ref={cycleDropdownRef} className="relative"> | |
| <FormControl> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| className={cn( | |
| "w-full justify-between", | |
| !field.value && "text-muted-foreground" | |
| )} | |
| disabled={isLoading || availableCycles.length === 0} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| setCycleDropdownOpen(!cycleDropdownOpen); | |
| }} | |
| > | |
| {field.value | |
| ? getCycleName(field.value) | |
| : "Select an appraisal cycle"} | |
| <ChevronDown className={cn( | |
| "ml-2 h-4 w-4 shrink-0 opacity-50", | |
| cycleDropdownOpen && "transform rotate-180" | |
| )} /> | |
| </Button> | |
| </FormControl> | |
| {cycleDropdownOpen && ( | |
| <div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg"> | |
| <div className="p-2"> | |
| <Input | |
| placeholder="Search cycles..." | |
| value={cycleSearch} | |
| onChange={(e) => setCycleSearch(e.target.value)} | |
| onClick={(e) => e.stopPropagation()} | |
| className="w-full mb-2" | |
| /> | |
| <div className="max-h-60 overflow-y-auto"> | |
| {filteredCycles.length === 0 ? ( | |
| <div className="text-center py-4 text-sm text-muted-foreground"> | |
| No cycles found | |
| </div> | |
| ) : ( | |
| <div className="space-y-1"> | |
| {filteredCycles.map((cycle) => { | |
| const isDisabled = !isPastCycle(cycle); | |
| return ( | |
| <Button | |
| key={cycle.cycleId} | |
| type="button" | |
| variant="ghost" | |
| className={cn( | |
| "w-full justify-start text-left font-normal", | |
| cycle.cycleId.toString() === field.value && "bg-accent text-accent-foreground", | |
| isDisabled && "opacity-50 cursor-not-allowed" | |
| )} | |
| disabled={isDisabled} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| if (!isDisabled) { | |
| form.setValue("cycleId", cycle.cycleId.toString()); | |
| setCycleDropdownOpen(false); | |
| } | |
| }} | |
| > | |
| <div className="flex items-center justify-between w-full"> | |
| <div className="flex items-center"> | |
| <span>{cycle.name}</span> | |
| {isDisabled && ( | |
| <span className="ml-2 text-xs text-muted-foreground"> | |
| (Not yet completed) | |
| </span> | |
| )} | |
| </div> | |
| {cycle.cycleId.toString() === field.value && ( | |
| <Check className="h-4 w-4" /> | |
| )} | |
| </div> | |
| </Button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={form.control} | |
| name="appraiserId" | |
| render={({ field }) => ( | |
| <FormItem className="flex flex-col"> | |
| <FormLabel>Manager</FormLabel> | |
| <div ref={managerDropdownRef} className="relative"> | |
| <FormControl> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| className={cn( | |
| "w-full justify-between", | |
| !field.value && "text-muted-foreground" | |
| )} | |
| disabled={isLoading || isLoadingManagers} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| setManagerDropdownOpen(!managerDropdownOpen); | |
| }} | |
| > | |
| {field.value | |
| ? getManagerName(field.value) | |
| : isLoadingManagers | |
| ? "Loading managers..." | |
| : "Select your manager"} | |
| <ChevronDown className={cn( | |
| "ml-2 h-4 w-4 shrink-0 opacity-50", | |
| managerDropdownOpen && "transform rotate-180" | |
| )} /> | |
| </Button> | |
| </FormControl> | |
| {managerDropdownOpen && ( | |
| <div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg"> | |
| <div className="p-2"> | |
| <Input | |
| placeholder="Search managers..." | |
| value={managerSearch} | |
| onChange={(e) => setManagerSearch(e.target.value)} | |
| onClick={(e) => e.stopPropagation()} | |
| className="w-full mb-2" | |
| /> | |
| <div className="max-h-60 overflow-y-auto"> | |
| {isLoadingManagers ? ( | |
| <div className="text-center py-4 text-sm text-muted-foreground"> | |
| Loading managers... | |
| </div> | |
| ) : filteredManagers.length === 0 ? ( | |
| <div className="text-center py-4 text-sm text-muted-foreground"> | |
| No managers found | |
| </div> | |
| ) : ( | |
| <div className="space-y-1"> | |
| {filteredManagers.map((manager) => ( | |
| <Button | |
| key={manager.id} | |
| type="button" | |
| variant="ghost" | |
| className={cn( | |
| "w-full justify-start text-left font-normal", | |
| manager.id.toString() === field.value && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| form.setValue("appraiserId", manager.id.toString()); | |
| setManagerDropdownOpen(false); | |
| }} | |
| > | |
| <div className="flex items-center justify-between w-full"> | |
| <span>{manager.name}</span> | |
| {manager.id.toString() === field.value && ( | |
| <Check className="h-4 w-4" /> | |
| )} | |
| </div> | |
| </Button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| {error && <p className="text-sm font-medium text-destructive">{error}</p>} | |
| <div className="flex justify-end space-x-2 pt-4"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| onClick={handleClose} | |
| disabled={isLoading} | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| type="submit" | |
| disabled={isLoading || availableCycles.length === 0} | |
| > | |
| {isLoading ? "Submitting..." : "Request Appraisal"} | |
| </Button> | |
| </div> | |
| </form> | |
| </Form> | |
| )} | |
| </CustomDialog> | |
| ); | |
| } |