Spaces:
Sleeping
Sleeping
| import React, { useEffect, useState } from "react"; | |
| import { useForm } from "react-hook-form"; | |
| import { zodResolver } from "@hookform/resolvers/zod"; | |
| import { z } from "zod"; | |
| import { Todo, TodoCreateRequest, TodoUpdateRequest } from "@/types"; | |
| import { Button } from "@/components/ui/button"; | |
| import { | |
| Form, | |
| FormControl, | |
| FormField, | |
| FormItem, | |
| FormLabel, | |
| FormMessage, | |
| } from "@/components/ui/form"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { Calendar } from "@/components/ui/calendar"; | |
| import { format } from "date-fns"; | |
| import { CalendarIcon, Check, ChevronDown, Loader2, Search, User, Briefcase } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { employeeService, projectService } from "@/lib/api"; | |
| import { Avatar, AvatarFallback } from "@/components/ui/avatar"; | |
| import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| // Custom dropdown component | |
| interface CustomDropdownProps { | |
| value: string; | |
| onChange: (value: string) => void; | |
| options: { value: string; label: string; color?: string }[]; | |
| placeholder: string; | |
| showBadge?: boolean; | |
| } | |
| function CustomDropdown({ value, onChange, options, placeholder, showBadge = false }: CustomDropdownProps) { | |
| const [open, setOpen] = useState(false); | |
| 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(); | |
| setOpen(!open); | |
| }; | |
| const handleOptionClick = (optionValue: string) => { | |
| onChange(optionValue); | |
| setOpen(false); | |
| }; | |
| 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" | |
| )} | |
| 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 p-1 text-popover-foreground shadow-md"> | |
| {options.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> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Custom Employee Dropdown component | |
| interface CustomEmployeeDropdownProps { | |
| value?: number; | |
| onChange: (value: number) => void; | |
| employees: Array<{id: number; firstName: string; lastName: string}>; | |
| isLoading: boolean; | |
| placeholder: string; | |
| } | |
| function CustomEmployeeDropdown({ value, onChange, employees, isLoading, placeholder }: CustomEmployeeDropdownProps) { | |
| const [open, setOpen] = useState(false); | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| const getEmployeeName = (id?: number) => { | |
| if (!id) return placeholder; | |
| const employee = employees.find(emp => emp.id === id); | |
| return employee ? `${employee.firstName} ${employee.lastName}` : `Employee ${id}`; | |
| }; | |
| const getInitials = (firstName: string, lastName: string) => { | |
| return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase(); | |
| }; | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| const handleOptionClick = (employeeId: number) => { | |
| onChange(employeeId); | |
| setOpen(false); | |
| }; | |
| // Filter employees based on search term | |
| const filteredEmployees = searchTerm | |
| ? employees.filter( | |
| emp => | |
| emp.firstName.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| emp.lastName.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ) | |
| : employees; | |
| 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" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| <div className="flex items-center"> | |
| {value && employees.find(e => e.id === value) ? ( | |
| <Avatar className="h-5 w-5 mr-2"> | |
| <AvatarFallback className="text-[10px]"> | |
| {getInitials( | |
| employees.find(e => e.id === value)!.firstName, | |
| employees.find(e => e.id === value)!.lastName | |
| )} | |
| </AvatarFallback> | |
| </Avatar> | |
| ) : ( | |
| <User className="h-4 w-4 mr-2 opacity-50" /> | |
| )} | |
| <span className={!value ? "text-muted-foreground truncate" : "truncate"}> | |
| {getEmployeeName(value)} | |
| </span> | |
| </div> | |
| <ChevronDown className="h-4 w-4 shrink-0 opacity-50" /> | |
| </div> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 w-full overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| <div className="flex items-center border-b px-3 py-2"> | |
| <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> | |
| <input | |
| className="flex h-8 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" | |
| placeholder="Search employee..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| </div> | |
| <div className="max-h-60 overflow-auto py-1"> | |
| {isLoading ? ( | |
| <div className="flex items-center justify-center p-4"> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| <span className="ml-2">Loading...</span> | |
| </div> | |
| ) : filteredEmployees.length === 0 ? ( | |
| <div className="text-center p-2 text-sm text-muted-foreground"> | |
| No employee found | |
| </div> | |
| ) : ( | |
| filteredEmployees.map((employee) => ( | |
| <div | |
| key={employee.id} | |
| className={cn( | |
| "flex cursor-pointer items-center px-2 py-1.5 hover:bg-accent hover:text-accent-foreground", | |
| value === employee.id && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={() => handleOptionClick(employee.id)} | |
| > | |
| <Avatar className="h-5 w-5 mr-2"> | |
| <AvatarFallback className="text-[10px]"> | |
| {getInitials(employee.firstName, employee.lastName)} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span>{employee.firstName} {employee.lastName}</span> | |
| {value === employee.id && ( | |
| <Check className="ml-auto h-4 w-4" /> | |
| )} | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Custom Project Dropdown component | |
| interface CustomProjectDropdownProps { | |
| value?: number; | |
| onChange: (value: number) => void; | |
| projects: Array<{id: number; projectName: string; projectCode: string}>; | |
| isLoading: boolean; | |
| placeholder: string; | |
| } | |
| function CustomProjectDropdown({ value, onChange, projects, isLoading, placeholder }: CustomProjectDropdownProps) { | |
| const [open, setOpen] = useState(false); | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| const getProjectName = (id?: number) => { | |
| if (!id) return placeholder; | |
| const project = projects.find(proj => proj.id === id); | |
| return project ? project.projectName : `Project ${id}`; | |
| }; | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| const handleOptionClick = (projectId: number) => { | |
| onChange(projectId); | |
| setOpen(false); | |
| }; | |
| // Filter projects based on search term | |
| const filteredProjects = searchTerm | |
| ? projects.filter( | |
| proj => | |
| proj.projectName.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| proj.projectCode.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ) | |
| : projects; | |
| 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" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| <div className="flex items-center"> | |
| {!value && <Briefcase className="h-4 w-4 mr-2 opacity-50" />} | |
| {value && <Briefcase className="h-4 w-4 mr-2" />} | |
| <span className={!value ? "text-muted-foreground truncate" : "truncate"}> | |
| {getProjectName(value)} | |
| </span> | |
| </div> | |
| <ChevronDown className="h-4 w-4 shrink-0 opacity-50" /> | |
| </div> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 w-full overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| <div className="flex items-center border-b px-3 py-2"> | |
| <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> | |
| <input | |
| className="flex h-8 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" | |
| placeholder="Search project..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| </div> | |
| <div className="max-h-60 overflow-auto py-1"> | |
| {isLoading ? ( | |
| <div className="flex items-center justify-center p-4"> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| <span className="ml-2">Loading...</span> | |
| </div> | |
| ) : filteredProjects.length === 0 ? ( | |
| <div className="text-center p-2 text-sm text-muted-foreground"> | |
| No project found | |
| </div> | |
| ) : ( | |
| filteredProjects.map((project) => ( | |
| <div | |
| key={project.id} | |
| className={cn( | |
| "flex cursor-pointer items-center px-2 py-1.5 hover:bg-accent hover:text-accent-foreground", | |
| value === project.id && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={() => handleOptionClick(project.id)} | |
| > | |
| <Briefcase className="h-4 w-4 mr-2" /> | |
| <span>{project.projectName}</span> | |
| <span className="ml-2 text-xs text-muted-foreground"> | |
| ({project.projectCode}) | |
| </span> | |
| {value === project.id && ( | |
| <Check className="ml-auto h-4 w-4" /> | |
| )} | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Custom DatePicker component | |
| interface CustomDatePickerProps { | |
| value: Date; | |
| onChange: (date: Date) => void; | |
| } | |
| function CustomDatePicker({ value, onChange }: CustomDatePickerProps) { | |
| const [open, setOpen] = useState(false); | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| 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" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| {value ? format(value, "PPP") : <span className="text-muted-foreground">Pick a date</span>} | |
| <CalendarIcon className="h-4 w-4 shrink-0 opacity-50" /> | |
| </div> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| <Calendar | |
| mode="single" | |
| selected={value} | |
| onSelect={(date) => { | |
| if (date) { | |
| onChange(date); | |
| setOpen(false); | |
| } | |
| }} | |
| initialFocus | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const todoSchema = z.object({ | |
| title: z.string().min(3, { message: "Title must be at least 3 characters" }), | |
| description: z.string().min(5, { message: "Description must be at least 5 characters" }), | |
| status: z.enum(["Pending", "In Progress", "Completed"], { | |
| required_error: "Please select a status", | |
| }), | |
| priority: z.enum(["Low", "Medium", "High"], { | |
| required_error: "Please select a priority", | |
| }), | |
| dueDate: z.date({ | |
| required_error: "Due date is required", | |
| }), | |
| assigneeId: z.number().optional(), | |
| projectId: z.number().optional(), | |
| }); | |
| type TodoFormValues = z.infer<typeof todoSchema>; | |
| type TodoFormProps = { | |
| todo?: Todo; | |
| onSubmit: (data: TodoCreateRequest | TodoUpdateRequest) => void; | |
| onCancel: () => void; | |
| isLoading?: boolean; | |
| }; | |
| const statusOptions = [ | |
| { value: "Pending", label: "Pending" }, | |
| { value: "In Progress", label: "In Progress" }, | |
| { value: "Completed", label: "Completed" }, | |
| ]; | |
| const priorityOptions = [ | |
| { value: "Low", label: "Low", color: "bg-green-100 text-green-800" }, | |
| { value: "Medium", label: "Medium", color: "bg-yellow-100 text-yellow-800" }, | |
| { value: "High", label: "High", color: "bg-red-100 text-red-800" }, | |
| ]; | |
| type Employee = { | |
| id: number; | |
| firstName: string; | |
| lastName: string; | |
| email: string; | |
| }; | |
| type Project = { | |
| id: number; | |
| projectName: string; | |
| projectCode: string; | |
| }; | |
| export function TodoForm({ todo, onSubmit, onCancel, isLoading = false }: TodoFormProps) { | |
| const { userData } = useAuth(); | |
| const [employees, setEmployees] = useState<Employee[]>([]); | |
| const [projects, setProjects] = useState<Project[]>([]); | |
| const [loadingEmployees, setLoadingEmployees] = useState(false); | |
| const [loadingProjects, setLoadingProjects] = useState(false); | |
| // Initialize form with default values or the provided todo | |
| const form = useForm<TodoFormValues>({ | |
| resolver: zodResolver(todoSchema), | |
| defaultValues: { | |
| title: "", | |
| description: "", | |
| status: "Pending", | |
| priority: "Medium", | |
| dueDate: new Date(), | |
| assigneeId: undefined, | |
| projectId: undefined, | |
| }, | |
| }); | |
| // Fetch employees and projects | |
| useEffect(() => { | |
| const fetchEmployees = async () => { | |
| setLoadingEmployees(true); | |
| try { | |
| const response = await employeeService.getAll(); | |
| if (Array.isArray(response)) { | |
| setEmployees(response); | |
| } | |
| } catch (error) { | |
| console.error("Failed to fetch employees:", error); | |
| } finally { | |
| setLoadingEmployees(false); | |
| } | |
| }; | |
| const fetchProjects = async () => { | |
| setLoadingProjects(true); | |
| try { | |
| const response = await projectService.getAll(); | |
| if (Array.isArray(response)) { | |
| setProjects(response); | |
| } | |
| } catch (error) { | |
| console.error("Failed to fetch projects:", error); | |
| } finally { | |
| setLoadingProjects(false); | |
| } | |
| }; | |
| fetchEmployees(); | |
| fetchProjects(); | |
| }, []); | |
| // Update form values when todo changes | |
| useEffect(() => { | |
| if (todo) { | |
| form.reset({ | |
| title: todo.title, | |
| description: todo.description, | |
| status: todo.status, | |
| priority: todo.priority, | |
| dueDate: new Date(todo.dueDate), | |
| assigneeId: todo.assigneeId, | |
| projectId: todo.projectId, | |
| }); | |
| } | |
| }, [todo, form]); | |
| const handleSubmit = (values: TodoFormValues) => { | |
| try { | |
| const todoData = { | |
| ...values, | |
| dueDate: format(values.dueDate, "yyyy-MM-dd"), | |
| }; | |
| // If creating a new todo and no assignee is selected, use the current user's employeeId | |
| if (!todo && !values.assigneeId && userData?.employeeId) { | |
| todoData.assigneeId = userData.employeeId; | |
| } | |
| if (todo) { | |
| onSubmit({ | |
| ...todoData, | |
| id: todo.id, | |
| } as TodoUpdateRequest); | |
| } else { | |
| onSubmit(todoData as TodoCreateRequest); | |
| } | |
| } catch (error) { | |
| console.error("Error submitting todo form:", error); | |
| // You can add additional error handling here if needed | |
| } | |
| }; | |
| return ( | |
| <Form {...form}> | |
| <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> | |
| <FormField | |
| control={form.control} | |
| name="title" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Title</FormLabel> | |
| <FormControl> | |
| <Input placeholder="Enter todo title" {...field} /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={form.control} | |
| name="description" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Description</FormLabel> | |
| <FormControl> | |
| <Textarea | |
| placeholder="Enter todo description" | |
| className="resize-none" | |
| {...field} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <FormField | |
| control={form.control} | |
| name="status" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Status</FormLabel> | |
| <FormControl> | |
| <CustomDropdown | |
| value={field.value} | |
| onChange={field.onChange} | |
| options={statusOptions} | |
| placeholder="Select status" | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={form.control} | |
| name="priority" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Priority</FormLabel> | |
| <FormControl> | |
| <CustomDropdown | |
| value={field.value} | |
| onChange={field.onChange} | |
| options={priorityOptions} | |
| placeholder="Select priority" | |
| showBadge={true} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| <FormField | |
| control={form.control} | |
| name="dueDate" | |
| render={({ field }) => ( | |
| <FormItem className="flex flex-col"> | |
| <FormLabel>Due Date</FormLabel> | |
| <FormControl> | |
| <CustomDatePicker | |
| value={field.value} | |
| onChange={field.onChange} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <FormField | |
| control={form.control} | |
| name="assigneeId" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Assignee</FormLabel> | |
| <FormControl> | |
| <CustomEmployeeDropdown | |
| value={field.value} | |
| onChange={field.onChange} | |
| employees={employees} | |
| isLoading={loadingEmployees} | |
| placeholder="Select assignee" | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={form.control} | |
| name="projectId" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Project</FormLabel> | |
| <FormControl> | |
| <CustomProjectDropdown | |
| value={field.value} | |
| onChange={field.onChange} | |
| projects={projects} | |
| isLoading={loadingProjects} | |
| placeholder="Select project" | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| <div className="flex items-center justify-end space-x-4 pt-4"> | |
| <Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}> | |
| Cancel | |
| </Button> | |
| <Button type="submit" disabled={isLoading}> | |
| {isLoading ? "Saving..." : todo ? "Update Todo" : "Create Todo"} | |
| </Button> | |
| </div> | |
| </form> | |
| </Form> | |
| ); | |
| } |