Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from "react"; | |
| import { useForm } from "react-hook-form"; | |
| import { zodResolver } from "@hookform/resolvers/zod"; | |
| import * as z from "zod"; | |
| import { CalendarIcon, Loader2, X, ChevronDown, Check, Search } from "lucide-react"; | |
| import { format } from "date-fns"; | |
| import { | |
| Form, | |
| FormControl, | |
| FormField, | |
| FormItem, | |
| FormLabel, | |
| FormMessage, | |
| } from "@/components/ui/form"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { Calendar } from "@/components/ui/calendar"; | |
| import { toast } from "@/lib/custom-toast"; | |
| import { tasksApi, TaskCreateRequest } from "@/services/tasksApi"; | |
| import { Employee } from "@/types"; | |
| import { cn } from "@/lib/utils"; | |
| import SlateRichTextEditor from "@/components/custom/SlateRichTextEditor"; | |
| // Form schema | |
| const formSchema = z.object({ | |
| title: z.string().min(1, "Title is required"), | |
| description: z.string().min(1, "Description is required"), | |
| type: z.string().min(1, "Type is required"), | |
| priority: z.string().min(1, "Priority is required"), | |
| status: z.string().min(1, "Status is required"), | |
| assignedTo: z.number().nullable(), | |
| estimates: z.string().optional(), | |
| stateDate: z.date(), | |
| endDate: z.date().nullable().optional(), | |
| }); | |
| // Custom dropdown component | |
| interface DropdownOption { | |
| value: string; | |
| label: string; | |
| } | |
| interface CustomDropdownProps { | |
| options: DropdownOption[]; | |
| value: string; | |
| onChange: (value: string) => void; | |
| placeholder?: string; | |
| } | |
| const CustomDropdown: React.FC<CustomDropdownProps> = ({ | |
| options, | |
| value, | |
| onChange, | |
| placeholder = "Select option" | |
| }) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const searchInputRef = useRef<HTMLInputElement>(null); | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| } | |
| }; | |
| document.addEventListener("mousedown", handleClickOutside); | |
| return () => { | |
| document.removeEventListener("mousedown", handleClickOutside); | |
| }; | |
| }, []); | |
| // Focus search input when dropdown opens | |
| useEffect(() => { | |
| if (isOpen && searchInputRef.current && options.length > 5) { | |
| setTimeout(() => { | |
| searchInputRef.current?.focus(); | |
| }, 10); | |
| } | |
| }, [isOpen, options.length]); | |
| // Clear search when dropdown closes | |
| useEffect(() => { | |
| if (!isOpen) { | |
| setSearchQuery(''); | |
| } | |
| }, [isOpen]); | |
| const selectedOption = options.find(option => option.value === value); | |
| // Filter options based on search query | |
| const filteredOptions = searchQuery | |
| ? options.filter(option => | |
| option.label.toLowerCase().includes(searchQuery.toLowerCase()) | |
| ) | |
| : options; | |
| return ( | |
| <div className="relative w-full" ref={dropdownRef}> | |
| <button | |
| type="button" | |
| onClick={() => setIsOpen(!isOpen)} | |
| className="flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" | |
| > | |
| <span className={selectedOption ? "" : "text-muted-foreground"}> | |
| {selectedOption ? selectedOption.label : placeholder} | |
| </span> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </button> | |
| {isOpen && ( | |
| <div className="absolute z-10 mt-1 w-full rounded-md border border-input bg-popover shadow-md"> | |
| {options.length > 5 && ( | |
| <div className="p-2 border-b border-input"> | |
| <div className="flex items-center gap-2 bg-background rounded-md border border-input px-2"> | |
| <Search className="h-3.5 w-3.5 opacity-70" /> | |
| <input | |
| ref={searchInputRef} | |
| type="text" | |
| placeholder="Search..." | |
| value={searchQuery} | |
| onChange={e => setSearchQuery(e.target.value)} | |
| onClick={e => e.stopPropagation()} | |
| className="flex w-full h-8 bg-transparent py-1 text-sm outline-none placeholder:text-muted-foreground" | |
| /> | |
| {searchQuery && ( | |
| <button | |
| type="button" | |
| onClick={e => { | |
| e.stopPropagation(); | |
| setSearchQuery(''); | |
| searchInputRef.current?.focus(); | |
| }} | |
| className="h-4 w-4 p-0 opacity-70 hover:opacity-100" | |
| > | |
| <X className="h-3 w-3" /> | |
| <span className="sr-only">Clear search</span> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| <ul className="max-h-[200px] overflow-auto py-1"> | |
| {filteredOptions.length === 0 ? ( | |
| <li className="px-3 py-2 text-sm text-muted-foreground text-center">No options found</li> | |
| ) : ( | |
| filteredOptions.map((option) => ( | |
| <li | |
| key={option.value} | |
| onClick={() => { | |
| onChange(option.value); | |
| setIsOpen(false); | |
| }} | |
| className={cn( | |
| "flex items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground", | |
| value === option.value && "bg-accent/50" | |
| )} | |
| > | |
| <span className="flex-grow">{option.label}</span> | |
| {value === option.value && <Check className="h-4 w-4" />} | |
| </li> | |
| )) | |
| )} | |
| </ul> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| // Custom DatePicker component | |
| interface CustomDatePickerProps { | |
| value: Date | null; | |
| onChange: (date: Date | null) => void; | |
| placeholder?: string; | |
| } | |
| const CustomDatePicker: React.FC<CustomDatePickerProps> = ({ | |
| value, | |
| onChange, | |
| placeholder = "Pick a date" | |
| }) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const datePickerRef = useRef<HTMLDivElement>(null); | |
| // Close date picker when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) { | |
| setIsOpen(false); | |
| } | |
| }; | |
| document.addEventListener("mousedown", handleClickOutside); | |
| return () => { | |
| document.removeEventListener("mousedown", handleClickOutside); | |
| }; | |
| }, []); | |
| return ( | |
| <div className="relative w-full" ref={datePickerRef}> | |
| <button | |
| type="button" | |
| onClick={() => setIsOpen(!isOpen)} | |
| className="flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" | |
| > | |
| <span className={value ? "" : "text-muted-foreground"}> | |
| {value ? format(value, "PPP") : placeholder} | |
| </span> | |
| <CalendarIcon className="h-4 w-4 opacity-50" /> | |
| </button> | |
| {isOpen && ( | |
| <div className="fixed top-0 left-0 right-0 z-50 flex justify-center pt-4 px-4 pb-20 items-start bg-black/50 overflow-auto min-h-screen"> | |
| <div className="bg-white dark:bg-gray-900 rounded-lg p-4 shadow-lg"> | |
| <div className="flex justify-between items-center mb-2"> | |
| <h3 className="text-base font-medium">Select Date</h3> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-8 w-8 p-0" | |
| onClick={() => setIsOpen(false)} | |
| > | |
| <X className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| <Calendar | |
| mode="single" | |
| selected={value || undefined} | |
| onSelect={(date) => { | |
| onChange(date); | |
| setIsOpen(false); | |
| }} | |
| initialFocus | |
| className="rounded border" | |
| /> | |
| <div className="mt-2 flex justify-end"> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| onClick={() => setIsOpen(false)} | |
| > | |
| Close | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| interface SimpleAddTaskDialogProps { | |
| issueId: number; | |
| onTaskAdded: () => void; | |
| employees: Employee[]; | |
| isOpen: boolean; | |
| onClose: () => void; | |
| } | |
| const SimpleAddTaskDialog: React.FC<SimpleAddTaskDialogProps> = ({ | |
| issueId, | |
| onTaskAdded, | |
| employees, | |
| isOpen, | |
| onClose, | |
| }) => { | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| console.log("Simple Dialog isOpen state:", isOpen); // Debug log | |
| // Initialize the form | |
| const form = useForm<z.infer<typeof formSchema>>({ | |
| resolver: zodResolver(formSchema), | |
| defaultValues: { | |
| title: "", | |
| description: "", | |
| type: "Task", | |
| priority: "Medium", | |
| status: "New", | |
| assignedTo: null, | |
| estimates: "", | |
| stateDate: new Date(), | |
| endDate: null, | |
| }, | |
| }); | |
| // Reset form when dialog opens | |
| useEffect(() => { | |
| if (isOpen) { | |
| console.log("Simple Dialog opened, resetting form"); // Debug log | |
| form.reset(); | |
| } | |
| }, [isOpen, form]); | |
| // Handle form submission | |
| const onSubmit = async (values: z.infer<typeof formSchema>) => { | |
| setIsSubmitting(true); | |
| try { | |
| // Prepare task data | |
| const taskData: TaskCreateRequest = { | |
| title: values.title, | |
| description: values.description, | |
| type: values.type, | |
| priority: values.priority, | |
| status: values.status, | |
| assignedTo: values.assignedTo, | |
| estimates: values.estimates || "0", | |
| esUnit: 1, // Default to hours | |
| stateDate: values.stateDate.toISOString().split('T')[0], | |
| endDate: values.endDate ? values.endDate.toISOString().split('T')[0] : null, | |
| remainingHr: 0, | |
| sprintName: null, | |
| issuesId: issueId, | |
| }; | |
| console.log("Creating task with data:", taskData); // Debug log | |
| // Create the task | |
| await tasksApi.create(taskData); | |
| // Show success message | |
| toast.success("Task created successfully"); | |
| // Close the dialog and refresh the tasks list | |
| onClose(); | |
| onTaskAdded(); | |
| } catch (error) { | |
| console.error("Error creating task:", error); | |
| toast.error("Failed to create task. Please try again."); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| // Dropdown options | |
| const typeOptions = [ | |
| { value: "Task", label: "Task" } | |
| ]; | |
| const priorityOptions = [ | |
| { value: "Low", label: "Low" }, | |
| { value: "Medium", label: "Medium" }, | |
| { value: "High", label: "High" }, | |
| { value: "Critical", label: "Critical" }, | |
| ]; | |
| const statusOptions = [ | |
| { value: "New", label: "New" }, | |
| { value: "In Progress", label: "In Progress" }, | |
| { value: "In Review", label: "In Review" }, | |
| { value: "Completed", label: "Completed" }, | |
| { value: "Closed", label: "Closed" }, | |
| ]; | |
| const assigneeOptions = [ | |
| { value: "null", label: "Unassigned" }, | |
| ...employees.map(emp => ({ | |
| value: emp.id.toString(), | |
| label: `${emp.firstName} ${emp.lastName}` | |
| })) | |
| ]; | |
| if (!isOpen) return null; | |
| return ( | |
| <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"> | |
| <div className="bg-white dark:bg-gray-900 rounded-lg w-full max-w-[550px] p-6 max-h-[90vh] overflow-y-auto"> | |
| <div className="flex justify-between items-center mb-4"> | |
| <div> | |
| <h2 className="text-lg font-semibold">Add New Task</h2> | |
| <p className="text-sm text-muted-foreground">Create a new task linked to this issue.</p> | |
| </div> | |
| <Button variant="ghost" size="icon" onClick={onClose}> | |
| <X className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| <Form {...form}> | |
| <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5"> | |
| <FormField | |
| control={form.control} | |
| name="title" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Title</FormLabel> | |
| <FormControl> | |
| <Input placeholder="Task title" {...field} /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={form.control} | |
| name="description" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Description</FormLabel> | |
| <FormControl> | |
| <SlateRichTextEditor | |
| initialValue={field.value || ''} | |
| onChange={(value) => field.onChange(value)} | |
| placeholder="Task description" | |
| minHeight="150px" | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <FormField | |
| control={form.control} | |
| name="type" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Type</FormLabel> | |
| <FormControl> | |
| <CustomDropdown | |
| options={typeOptions} | |
| value={field.value} | |
| onChange={field.onChange} | |
| placeholder="Select type" | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={form.control} | |
| name="priority" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Priority</FormLabel> | |
| <FormControl> | |
| <CustomDropdown | |
| options={priorityOptions} | |
| value={field.value} | |
| onChange={field.onChange} | |
| placeholder="Select priority" | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <FormField | |
| control={form.control} | |
| name="status" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Status</FormLabel> | |
| <FormControl> | |
| <CustomDropdown | |
| options={statusOptions} | |
| value={field.value} | |
| onChange={field.onChange} | |
| placeholder="Select status" | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={form.control} | |
| name="assignedTo" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Assigned To</FormLabel> | |
| <FormControl> | |
| <CustomDropdown | |
| options={assigneeOptions} | |
| value={field.value ? field.value.toString() : "null"} | |
| onChange={(value) => field.onChange(value === "null" ? null : parseInt(value))} | |
| placeholder="Select assignee" | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <FormField | |
| control={form.control} | |
| name="stateDate" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Start Date</FormLabel> | |
| <FormControl> | |
| <CustomDatePicker | |
| value={field.value} | |
| onChange={field.onChange} | |
| placeholder="Pick a start date" | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <FormField | |
| control={form.control} | |
| name="endDate" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Due Date</FormLabel> | |
| <FormControl> | |
| <CustomDatePicker | |
| value={field.value} | |
| onChange={field.onChange} | |
| placeholder="Pick a due date" | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| </div> | |
| <FormField | |
| control={form.control} | |
| name="estimates" | |
| render={({ field }) => ( | |
| <FormItem> | |
| <FormLabel>Estimated Hours</FormLabel> | |
| <FormControl> | |
| <Input | |
| type="number" | |
| placeholder="Estimated hours to complete" | |
| {...field} | |
| /> | |
| </FormControl> | |
| <FormMessage /> | |
| </FormItem> | |
| )} | |
| /> | |
| <div className="flex justify-end gap-2 pt-2"> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| onClick={onClose} | |
| disabled={isSubmitting} | |
| > | |
| Cancel | |
| </Button> | |
| <Button type="submit" disabled={isSubmitting}> | |
| {isSubmitting ? ( | |
| <> | |
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | |
| Creating... | |
| </> | |
| ) : ( | |
| "Create Task" | |
| )} | |
| </Button> | |
| </div> | |
| </form> | |
| </Form> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default SimpleAddTaskDialog; |