From 8af9aba0a7c43dcf49be18709d2933f49d1c3b3d Mon Sep 17 00:00:00 2001 From: raineer Date: Wed, 17 Sep 2025 02:01:05 +0800 Subject: [PATCH 1/5] quick assignee selection --- .../[id]/_components/tasks/TaskViewModal.tsx | 146 +++++++++++++++--- 1 file changed, 125 insertions(+), 21 deletions(-) diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx index a45236041..6968e33bd 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx @@ -65,6 +65,7 @@ const TaskViewModal = ({ const { isOpen, onOpen, onClose, type, data } = useModal(); const [isLoading, setIsLoading] = useState(false); const [updateLoading, setUpdateLoading] = useState(false); + const [assigneeLoading, setAssigneeLoading] = useState(false); const isModalOpen = isOpen && type === "taskViewModal"; const task = data as Task | null; const user = useUserStore((state) => state.user); @@ -137,7 +138,6 @@ const TaskViewModal = ({ }; // State for Skill Category, Sidekick Details, and Primary Assignee - const [skillCategory, setSkillCategory] = useState( null, ); @@ -146,6 +146,7 @@ const TaskViewModal = ({ null, ); const [createdBy, setCreatedBy] = useState(null); + const [availableCodevs, setAvailableCodevs] = useState([]); const [supabase, setSupabase] = useState(null); useEffect(() => { @@ -153,6 +154,64 @@ const TaskViewModal = ({ setSupabase(supabaseClient); }, []); + // Fetch available codevs for assignment + useEffect(() => { + if (!supabase) return; + + const fetchAvailableCodevs = async () => { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("status", "active") + .order("first_name"); + + if (!error && data) { + setAvailableCodevs(data as CodevMember[]); + } + }; + + fetchAvailableCodevs(); + }, [supabase]); + + // Handle assignee selection + const handleAssigneeSelect = async (codevId: string) => { + if (!task || !supabase) return; + + setAssigneeLoading(true); + + try { + const { error } = await supabase + .from("task") + .update({ codev_id: codevId }) + .eq("id", task.id); + + if (!error) { + toast.success("Assignee updated successfully"); + + // Update local state + const selectedCodev = availableCodevs.find(c => c.id === codevId); + if (selectedCodev) { + setPrimaryAssignee(selectedCodev); + // Update task object + if (task) { + task.codev_id = codevId; + task.codev = selectedCodev as any; + } + } + + // Refetch board data + await fetchBoardData(); + } else { + toast.error("Failed to update assignee"); + } + } catch (error) { + console.error("Error updating assignee:", error); + toast.error("Failed to update assignee"); + } + + setAssigneeLoading(false); + }; + // Set the skill category from the task useEffect(() => { if (!supabase) return; @@ -428,27 +487,72 @@ const TaskViewModal = ({ )} - {/* Primary Assignee */} + {/* Primary Assignee - MODIFIED SECTION */}
-
- {primaryAssignee && primaryAssignee.image_url ? ( - {`${primaryAssignee.first_name} - ) : ( - - )} - - {primaryAssignee && task - ? `${primaryAssignee.first_name} ${primaryAssignee.last_name}` - : "Unassigned"} - -
+ {primaryAssignee ? ( + // Show assigned member +
+ {primaryAssignee.image_url ? ( + {`${primaryAssignee.first_name} + ) : ( + + )} + + {`${primaryAssignee.first_name} ${primaryAssignee.last_name}`} + +
+ ) : ( + // Show assignee selection dropdown when unassigned +
+ {canModifyTask ? ( + + ) : ( +
+ + Unassigned +
+ )} + {assigneeLoading && ( + + )} +
+ )}
@@ -549,4 +653,4 @@ const TaskViewModal = ({ ); }; -export default TaskViewModal; +export default TaskViewModal; \ No newline at end of file From 63fdf4799b28fddab249590e682d635d88ba5746 Mon Sep 17 00:00:00 2001 From: raineer Date: Wed, 17 Sep 2025 14:26:01 +0800 Subject: [PATCH 2/5] add members dropdown --- .../kanban_modals/KanbanAddModalMembers.tsx | 909 +++++++++++++++--- .../[id]/_components/tasks/TaskViewModal.tsx | 894 +++++++++++------ 2 files changed, 1381 insertions(+), 422 deletions(-) diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx index e391a6a8a..d351280cc 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx @@ -1,11 +1,31 @@ +"use client"; + import { useEffect, useState } from "react"; import Image from "next/image"; +import DefaultAvatar from "@/components/DefaultAvatar"; import { Button } from "@/components/ui/button"; -import { getCachedUser } from "@/lib/server/supabase-server-comp"; -import { IconPlus } from "@/public/assets/svgs"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useModal } from "@/hooks/use-modal"; +import { useUserStore } from "@/store/codev-store"; +import { useKanbanStore } from "@/store/kanban-store"; +import { SkillCategory, Task } from "@/types/home/codev"; import { createClientClientComponent } from "@/utils/supabase/client"; +import { Ellipsis, Loader2Icon } from "lucide-react"; +import toast from "react-hot-toast"; -import { cn } from "@codevs/ui"; import { DropdownMenu, DropdownMenuContent, @@ -14,9 +34,28 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@codevs/ui/dropdown-menu"; +import { Input } from "@codevs/ui/input"; +import { Label } from "@codevs/ui/label"; +import { IconPlus } from "@/public/assets/svgs"; + +import { completeTask, updateTaskPRLink, fetchAvailableMembers } from "../../actions"; +import DifficultyPointsTooltip, { + DIFFICULTY_LEVELS, +} from "../DifficultyPointsTooltip"; -import { fetchAvailableMembers } from "../../actions"; +// ============================================================================ +// CONSTANTS - Following DRY principle +// ============================================================================ +const PRIORITY_LEVELS = ["critical", "high", "medium", "low"]; +const BUTTON_STYLES = { + primary: "text-md bg-customBlue-100 hover:bg-customBlue-200 focus-visible:ring-customBlue-100 flex h-10 w-full items-center justify-center gap-2 whitespace-nowrap rounded-md px-6 py-1 text-white ring-offset-background transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:w-auto lg:text-lg", + secondary: "text-grey-100 bg-light-900 dark:bg-black-200 mt-4 w-full border-2 border-gray-300 py-4 text-black hover:bg-green-700 sm:w-auto" +}; + +// ============================================================================ +// INTERFACES - Following SOLID principle +// ============================================================================ interface CodevMember { id: string; first_name: string; @@ -24,155 +63,137 @@ interface CodevMember { image_url?: string | null; } -interface Props { - initialSelectedMembers?: string[]; // Array of member IDs - onMembersChange?: (memberIds: string[]) => void; - projectId: string; - /** Optional list of member IDs that should be disabled from selection */ - disabledMembers?: string[]; - /** If true, only one member can be selected */ - singleSelection?: boolean; -} - -export default function KanbanAddModalMembers({ - initialSelectedMembers = [], - onMembersChange, - projectId, - disabledMembers = [], - singleSelection = false, -}: Props) { - const [searchQuery, setSearchQuery] = useState(""); - const [selectedMemberIds, setSelectedMemberIds] = useState( - initialSelectedMembers, - ); - const [user, setUser] = useState(null); +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ +const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); +// ============================================================================ +// AGGRESSIVE ASSIGNEE SELECTOR - IMMEDIATE UI UPDATES +// ============================================================================ +function AssigneeSelector({ + primaryAssignee, + onAssigneeChange, + boardId, + user, + forceRefreshKey // Add this to force complete re-render when needed +}: { + primaryAssignee: CodevMember | null; + onAssigneeChange: (memberIds: string[]) => void; + boardId: string; + user: any; + forceRefreshKey?: string; +}) { const [availableMembers, setAvailableMembers] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [supabase, setSupabase] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [localAssignee, setLocalAssignee] = useState(primaryAssignee); - // useEffect(() => { - // const loadMembers = async () => { - // try { - // const members = await fetchAvailableMembers(projectId); - // setAvailableMembers(members); - // } catch (error) { - // console.error("Error loading members:", error); - // } finally { - // setIsLoading(false); - // } - // }; - - // loadMembers(); - // }, [projectId]); + // Sync with prop changes but prioritize local state for immediate feedback useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); + setLocalAssignee(primaryAssignee); + }, [primaryAssignee, forceRefreshKey]); + // Load members when boardId changes useEffect(() => { - if (!supabase) return; - + if (!boardId) { + setAvailableMembers([]); + setIsLoading(false); + return; + } + const loadMembers = async () => { + setIsLoading(true); try { - // console.log("Fetching members for project ID:", projectId); - const members = await fetchAvailableMembers(projectId); - // console.log("Fetched members:", members); // Debugging log - const user = await supabase.auth.getUser(); - // console.log("Current user:", user); // Debugging log - - if (Array.isArray(members)) { + console.log("=== LOADING MEMBERS FOR BOARD:", boardId, "==="); + const members = await fetchAvailableMembers(boardId); + console.log("=== FETCHED MEMBERS:", members, "==="); + + if (Array.isArray(members) && members.length > 0) { setAvailableMembers(members); + console.log("=== SET AVAILABLE MEMBERS:", members.length, "==="); } else { - console.error("Expected an array but got:", members); + console.warn("=== NO MEMBERS FOUND OR INVALID RESPONSE ==="); + setAvailableMembers([]); } - - setUser(user.data.user); } catch (error) { - console.error("Error loading members:", error); + console.error("=== ERROR LOADING MEMBERS:", error, "==="); + setAvailableMembers([]); } finally { setIsLoading(false); } }; - if (projectId) { - loadMembers(); - } - }, [projectId, supabase]); + loadMembers(); + }, [boardId]); - const selectedMembers = availableMembers.filter((member) => - selectedMemberIds.includes(member.id), + const filteredMembers = availableMembers.filter((member) => + `${member.first_name} ${member.last_name}` + .toLowerCase() + .includes(searchQuery.toLowerCase()), ); - const addMember = (memberId: string) => { - if (singleSelection) { - const newIds = [memberId]; - setSelectedMemberIds(newIds); - onMembersChange?.(newIds); - } else if (!selectedMemberIds.includes(memberId)) { - const newIds = [...selectedMemberIds, memberId]; - setSelectedMemberIds(newIds); - onMembersChange?.(newIds); - } + // IMMEDIATE REMOVAL - Updates UI instantly + const handleRemove = () => { + console.log("=== REMOVING ASSIGNEE IMMEDIATELY ==="); + setLocalAssignee(null); // IMMEDIATE UI UPDATE + onAssigneeChange([]); // Trigger parent update }; - const removeMember = (memberId: string) => { - const newSelectedIds = selectedMemberIds.filter((id) => id !== memberId); - setSelectedMemberIds(newSelectedIds); - onMembersChange?.(newSelectedIds); // Ensure this triggers parent update + // IMMEDIATE SELECTION - Updates UI instantly + const handleSelect = (memberId: string) => { + const selectedMember = availableMembers.find(m => m.id === memberId); + console.log("=== SELECTING MEMBER IMMEDIATELY:", selectedMember, "==="); + + if (selectedMember) { + setLocalAssignee(selectedMember); // IMMEDIATE UI UPDATE + onAssigneeChange([memberId]); // Trigger parent update + } }; const handleSelfAssign = () => { - if (user && !selectedMemberIds.includes(user.id)) { - const newSelectedIds = [...selectedMemberIds, user.id]; - setSelectedMemberIds(newSelectedIds); - onMembersChange?.(newSelectedIds); + if (user?.id) { + const userAsMember = { + id: user.id, + first_name: user.first_name || "You", + last_name: user.last_name || "", + image_url: user.image_url + }; + console.log("=== SELF ASSIGNING IMMEDIATELY:", userAsMember, "==="); + setLocalAssignee(userAsMember); // IMMEDIATE UI UPDATE + onAssigneeChange([user.id]); // Trigger parent update } }; - const filteredMembers = availableMembers.filter((member) => - `${member.first_name} ${member.last_name}` - .toLowerCase() - .includes(searchQuery.toLowerCase()), - ); - return (
- {/* */} -
- {selectedMembers.map((member) => ( + {/* Show current assignee with IMMEDIATE state */} + {localAssignee && (
removeMember(member.id)} - title={`${member.first_name} ${member.last_name}`} + onClick={handleRemove} + title={`${localAssignee.first_name} ${localAssignee.last_name} - Click to remove`} > - {member.image_url ? ( + {localAssignee.image_url ? ( {`${member.first_name}'s ) : (
- {member.first_name[0]} + {localAssignee.first_name[0]}
)}
✕
- ))} + )} + {/* Add member dropdown */} -
+ )} +
+ + {/* Debug info - remove in production */} +
+ Members loaded: {availableMembers.length} | Local assignee: {localAssignee?.first_name || 'None'}
); } + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ +const TaskViewModal = ({ + onComplete, +}: { + onComplete?: (taskId: string) => void; +}) => { + // ============================================================================ + // HOOKS AND STATE + // ============================================================================ + const { isOpen, onOpen, onClose, type, data } = useModal(); + const user = useUserStore((state) => state.user); + const { fetchBoardData } = useKanbanStore(); + + // Loading states + const [isLoading, setIsLoading] = useState(false); + const [updateLoading, setUpdateLoading] = useState(false); + + // Component state + const [prLink, setPrLink] = useState(""); + const [supabase, setSupabase] = useState(null); + const [boardId, setBoardId] = useState(""); + + // Data states + const [skillCategory, setSkillCategory] = useState(null); + const [sidekickDetails, setSidekickDetails] = useState([]); + const [primaryAssignee, setPrimaryAssignee] = useState(null); + const [createdBy, setCreatedBy] = useState(null); + const [forceRefreshKey, setForceRefreshKey] = useState(""); + + // Derived state + const isModalOpen = isOpen && type === "taskViewModal"; + const task = data as Task | null; + + // Permission checks + const canModifyTask = user?.role_id === 1 || user?.role_id === 5 || user?.role_id === 4 || user?.role_id === 10; + const canMarkAsDone = user?.role_id === 1 || user?.role_id === 5; + + // ============================================================================ + // EFFECTS + // ============================================================================ + + // Initialize Supabase client + useEffect(() => { + const supabaseClient = createClientClientComponent(); + setSupabase(supabaseClient); + }, []); + + // Fetch board ID from task's column + useEffect(() => { + if (!supabase || !task?.kanban_column_id) { + setBoardId(""); + return; + } + + const fetchBoardId = async () => { + try { + console.log("=== FETCHING BOARD ID FOR COLUMN:", task.kanban_column_id, "==="); + const { data, error } = await supabase + .from("kanban_columns") + .select("board_id") + .eq("id", task.kanban_column_id) + .single(); + + if (error) { + console.error("=== ERROR FETCHING BOARD ID:", error, "==="); + setBoardId(""); + } else if (data?.board_id) { + console.log("=== FOUND BOARD ID:", data.board_id, "==="); + setBoardId(data.board_id); + } + } catch (err) { + console.error("=== EXCEPTION FETCHING BOARD ID:", err, "==="); + setBoardId(""); + } + }; + + fetchBoardId(); + }, [task?.kanban_column_id, supabase]); + + // Reset states when task changes - AGGRESSIVE RESET + useEffect(() => { + if (task?.id) { + console.log("=== TASK CHANGED, RESETTING STATES ==="); + setPrLink(task?.pr_link || ""); + setPrimaryAssignee(null); + setForceRefreshKey(Date.now().toString()); // Force component refresh + } + }, [task?.id]); + + // Fetch skill category + useEffect(() => { + if (!supabase || !task?.skill_category_id) return; + + const fetchSkillCategory = async () => { + const { data, error } = await supabase + .from("skill_category") + .select("id, name") + .eq("id", task.skill_category_id) + .single(); + if (!error && data) { + setSkillCategory(data as SkillCategory); + } + }; + fetchSkillCategory(); + }, [task?.skill_category_id, supabase]); + + // Fetch sidekick details + useEffect(() => { + if (!supabase || !task?.sidekick_ids?.length) return; + + const fetchSidekickDetails = async () => { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .in("id", task.sidekick_ids); + if (!error && data) { + setSidekickDetails(data as CodevMember[]); + } + }; + fetchSidekickDetails(); + }, [task?.sidekick_ids, supabase]); + + // Fetch primary assignee - AGGRESSIVE FETCHING + useEffect(() => { + if (!supabase || !task) return; + + const fetchPrimaryAssignee = async () => { + const assigneeId = task?.codev_id || task?.codev?.id; + + console.log("=== FETCHING PRIMARY ASSIGNEE, ID:", assigneeId, "==="); + + // Always reset first + setPrimaryAssignee(null); + + if (assigneeId) { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", assigneeId) + .single(); + + if (!error && data) { + console.log("=== FOUND ASSIGNEE DATA:", data, "==="); + setPrimaryAssignee(data as CodevMember); + } else { + console.log("=== NO ASSIGNEE FOUND, ERROR:", error, "==="); + } + } else if (task?.codev) { + console.log("=== USING TASK.CODEV DIRECTLY:", task.codev, "==="); + setPrimaryAssignee({ + id: task.codev.id, + first_name: task.codev.first_name, + last_name: task.codev.last_name, + image_url: task.codev.image_url, + }); + } else { + console.log("=== NO ASSIGNEE DATA AVAILABLE ==="); + } + }; + + fetchPrimaryAssignee(); + }, [task, supabase]); + + // Fetch created by + useEffect(() => { + if (!supabase || !task?.created_by) return; + + const fetchCreatedBy = async () => { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", task.created_by) + .single(); + + if (!error && data) { + setCreatedBy(data as CodevMember); + } + }; + fetchCreatedBy(); + }, [task?.created_by, supabase]); + + // ============================================================================ + // EVENT HANDLERS + // ============================================================================ + + const handleUpdate = async () => { + if (!task) return; + + if (!prLink.trim()) { + toast.error("PR Link cannot be empty"); + return; + } + + setUpdateLoading(true); + + const response = await updateTaskPRLink(task.id, prLink); + + if (response.success) { + toast.success("PR Link updated successfully"); + setPrLink(prLink); + if (task) task.pr_link = prLink; + } else { + toast.error(response.error || "Failed to update PR Link"); + } + + setUpdateLoading(false); + }; + + const handleMarkAsDone = async () => { + if (!task) return; + + setIsLoading(true); + + try { + const result = await completeTask(task); + + if (result.success) { + toast.success("Task completed and points awarded!"); + + if (onComplete) { + onComplete(task.id); + } + + onClose(); + await fetchBoardData(); + } else { + toast.error(result.error || "Failed to complete task"); + } + } catch (error) { + console.error("Error completing task:", error); + toast.error("Failed to complete task"); + } + + setIsLoading(false); + }; + + // ============================================================================ + // AGGRESSIVE ASSIGNEE CHANGE HANDLER + // ============================================================================ + const handleAssigneeChange = async (memberIds: string[]) => { + if (!task || !supabase) return; + + const newAssigneeId = memberIds[0] || undefined; + console.log("=== ASSIGNMENT CHANGE:", { newAssigneeId, memberIds }, "==="); + + try { + // Update database + const { error } = await supabase + .from("tasks") + .update({ codev_id: newAssigneeId }) + .eq("id", task.id); + + if (!error) { + if (newAssigneeId) { + // Fetch new assignee data and update state + const { data: assigneeData } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", newAssigneeId) + .single(); + + if (assigneeData) { + console.log("=== UPDATING TASK OBJECT WITH NEW ASSIGNEE:", assigneeData, "==="); + setPrimaryAssignee(assigneeData); + if (task) { + task.codev_id = newAssigneeId; + task.codev = assigneeData as any; + } + toast.success(`Task assigned to ${assigneeData.first_name} ${assigneeData.last_name}`); + } + } else { + console.log("=== REMOVING ASSIGNEE FROM TASK ==="); + setPrimaryAssignee(null); + if (task) { + task.codev_id = undefined; + task.codev = undefined; + } + toast.success("Task unassigned successfully"); + } + + // Force refresh key to trigger component re-render + setForceRefreshKey(Date.now().toString()); + + // Refresh board data + await fetchBoardData(); + } else { + console.error("=== DATABASE ERROR:", error, "==="); + toast.error("Failed to update assignee"); + } + } catch (error) { + console.error("=== ERROR UPDATING ASSIGNEE:", error, "==="); + toast.error("Failed to update assignee"); + } + }; + + // Early return + if (!isModalOpen) return null; + + // ============================================================================ + // RENDER + // ============================================================================ + return ( + + +
+ {/* Header */} +
+ + + {task?.title} + + + {canModifyTask && ( + + + + + + onOpen("taskEditModal", task)} + > + Edit + + {user?.role_id !== 4 && ( + onOpen("taskDeleteModal", task)} + className="text-red-500 focus:text-red-500" + > + Delete + + )} + + + )} +
+ + {/* Form Fields */} +
+ {/* Task Title */} +
+ + +
+ + {/* Points */} +
+ + +
+ + {/* Priority */} +
+ + +
+ + {/* Difficulty */} +
+
+ + +
+ +
+ + {/* Task Type */} +
+ + +
+ + {/* PR Link */} +
+ +
+ setPrLink(e.target.value)} + onBlur={() => { + if (!prLink.trim() && task?.pr_link) { + setPrLink(task.pr_link); + } + }} + className="text-grey-100 bg-light-900 dark:bg-dark-200 dark:text-light-900 focus:border-customBlue-500 border border-gray-300" + placeholder="Enter PR Link..." + /> + +
+
+ + {/* Skill Category */} +
+ + {skillCategory ? ( +
+ {skillCategory.name} +
+ ) : ( +
None assigned
+ )} +
+ + {/* ================================================================ */} + {/* WORKING PRIMARY ASSIGNEE SELECTOR */} + {/* ================================================================ */} +
+ + {canModifyTask ? ( +
+ {boardId ? ( + + ) : ( +
Loading board...
+ )} +
+ ) : primaryAssignee ? ( +
+ {primaryAssignee.image_url ? ( + {`${primaryAssignee.first_name} + ) : ( + + )} + + {`${primaryAssignee.first_name} ${primaryAssignee.last_name}`} + +
+ ) : ( +
+ + Unassigned +
+ )} +
+
+ + {/* Team Members (Sidekicks) */} + {task?.sidekick_ids && task.sidekick_ids.length > 0 && ( +
+ +
+ {sidekickDetails.length > 0 + ? sidekickDetails.map((member) => ( +
+ {member.image_url ? ( + {`${member.first_name} + ) : ( + + )} +
+ )) + : task.sidekick_ids.map((memberId) => ( +
+ +
+ ))} +
+
+ )} + + {/* Description */} +
+ +
+
+ + {/* Created By */} +
+ +
+ {createdBy?.image_url ? ( + {`${createdBy.first_name} + ) : ( + + )} + + {createdBy + ? `${createdBy.first_name} ${createdBy.last_name}` + : "Unknown"} + +
+
+ + {/* Footer Actions */} + + + {canMarkAsDone && task?.pr_link && ( + + )} + +
+ +
+ ); +}; + +export default TaskViewModal; \ No newline at end of file diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx index 6968e33bd..4365323c6 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx @@ -1,9 +1,7 @@ "use client"; -import { resourceLimits } from "worker_threads"; import { useEffect, useState } from "react"; import Image from "next/image"; -import { useRouter } from "next/navigation"; import DefaultAvatar from "@/components/DefaultAvatar"; import { Button } from "@/components/ui/button"; import { @@ -16,7 +14,6 @@ import { import { Select, SelectContent, - SelectGroup, SelectItem, SelectTrigger, SelectValue, @@ -26,7 +23,6 @@ import { useUserStore } from "@/store/codev-store"; import { useKanbanStore } from "@/store/kanban-store"; import { SkillCategory, Task } from "@/types/home/codev"; import { createClientClientComponent } from "@/utils/supabase/client"; -import { set } from "date-fns"; import { Ellipsis, Loader2Icon } from "lucide-react"; import toast from "react-hot-toast"; @@ -34,17 +30,124 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@codevs/ui/dropdown-menu"; import { Input } from "@codevs/ui/input"; import { Label } from "@codevs/ui/label"; -import { Textarea } from "@codevs/ui/textarea"; +import { IconPlus } from "@/public/assets/svgs"; import { completeTask, updateTaskPRLink } from "../../actions"; import DifficultyPointsTooltip, { DIFFICULTY_LEVELS, } from "../DifficultyPointsTooltip"; +// ============================================================================ +// WORKING fetchAvailableMembers implementation with debug logging +// ============================================================================ +const fetchAvailableMembers = async (boardId: string): Promise => { + try { + const supabase = createClientClientComponent(); + + if (!supabase) { + console.error("=== Supabase client not initialized ==="); + return []; + } + + console.log("=== STARTING fetchAvailableMembers for boardId:", boardId, "==="); + + // 1. Get the project_id from the kanban_boards table + const { data: board, error: boardError } = await supabase + .from("kanban_boards") + .select("project_id") + .eq("id", boardId) + .single(); + + if (boardError) { + console.error("=== Board fetch error:", boardError, "==="); + return []; + } + + if (!board?.project_id) { + console.error("=== No project associated with board:", boardId, "==="); + return []; + } + + console.log("=== Found project_id:", board.project_id, "==="); + + // 2. Get all project members from project_members table + const { data: projectMembers, error: projectMembersError } = await supabase + .from("project_members") + .select("codev_id, role") + .eq("project_id", board.project_id); + + if (projectMembersError) { + console.error("=== Project members fetch error:", projectMembersError, "==="); + return []; + } + + if (!projectMembers?.length) { + console.error("=== No project members found for project:", board.project_id, "==="); + return []; + } + + console.log("=== Found project members:", projectMembers, "==="); + + // 3. Collect ALL member IDs (including team leader) + const allMemberIds = projectMembers.map(member => member.codev_id); + console.log("=== All member IDs:", allMemberIds, "==="); + + // 4. Fetch ALL members' details from the codev table + const { data: codevMembers, error: codevError } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url, availability_status") + .in("id", allMemberIds); + + if (codevError) { + console.error("=== Codev members fetch error:", codevError, "==="); + return []; + } + + if (!codevMembers?.length) { + console.error("=== No codev details found for member IDs:", allMemberIds, "==="); + return []; + } + + console.log("=== Found codev members:", codevMembers, "==="); + + // 5. Filter available members and format for return + const availableMembers = codevMembers + .filter(member => member.availability_status === true) + .map(member => ({ + id: member.id, + first_name: member.first_name, + last_name: member.last_name, + image_url: member.image_url, + })); + + console.log("=== Final available members:", availableMembers, "==="); + return availableMembers; + + } catch (error) { + console.error("=== CRITICAL ERROR in fetchAvailableMembers:", error, "==="); + return []; + } +}; + +// ============================================================================ +// CONSTANTS - Following DRY principle +// ============================================================================ +const PRIORITY_LEVELS = ["critical", "high", "medium", "low"]; + +const BUTTON_STYLES = { + primary: "text-md bg-customBlue-100 hover:bg-customBlue-200 focus-visible:ring-customBlue-100 flex h-10 w-full items-center justify-center gap-2 whitespace-nowrap rounded-md px-6 py-1 text-white ring-offset-background transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:w-auto lg:text-lg", + secondary: "text-grey-100 bg-light-900 dark:bg-black-200 mt-4 w-full border-2 border-gray-300 py-4 text-black hover:bg-green-700 sm:w-auto" +}; + +// ============================================================================ +// INTERFACES - Following SOLID principle +// ============================================================================ interface CodevMember { id: string; first_name: string; @@ -52,34 +155,411 @@ interface CodevMember { image_url?: string | null; } -const PRIORITY_LEVELS = ["critical", "high", "medium", "low"]; - -// Utility function to capitalize the first letter +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); +// ============================================================================ +// AGGRESSIVE ASSIGNEE SELECTOR - IMMEDIATE UI UPDATES +// ============================================================================ +function AssigneeSelector({ + primaryAssignee, + onAssigneeChange, + boardId, + user, + forceRefreshKey // Add this to force complete re-render when needed +}: { + primaryAssignee: CodevMember | null; + onAssigneeChange: (memberIds: string[]) => void; + boardId: string; + user: any; + forceRefreshKey?: string; +}) { + const [availableMembers, setAvailableMembers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [localAssignee, setLocalAssignee] = useState(primaryAssignee); + + // Sync with prop changes but prioritize local state for immediate feedback + useEffect(() => { + setLocalAssignee(primaryAssignee); + }, [primaryAssignee, forceRefreshKey]); + + // Load members when boardId changes + useEffect(() => { + if (!boardId) { + setAvailableMembers([]); + setIsLoading(false); + return; + } + + const loadMembers = async () => { + setIsLoading(true); + try { + console.log("=== LOADING MEMBERS FOR BOARD:", boardId, "==="); + const members = await fetchAvailableMembers(boardId); + console.log("=== FETCHED MEMBERS:", members, "==="); + + if (Array.isArray(members) && members.length > 0) { + setAvailableMembers(members); + console.log("=== SET AVAILABLE MEMBERS:", members.length, "==="); + } else { + console.warn("=== NO MEMBERS FOUND OR INVALID RESPONSE ==="); + setAvailableMembers([]); + } + } catch (error) { + console.error("=== ERROR LOADING MEMBERS:", error, "==="); + setAvailableMembers([]); + } finally { + setIsLoading(false); + } + }; + + loadMembers(); + }, [boardId]); + + const filteredMembers = availableMembers.filter((member) => + `${member.first_name} ${member.last_name}` + .toLowerCase() + .includes(searchQuery.toLowerCase()), + ); + + // IMMEDIATE REMOVAL - Updates UI instantly with FORCED state reset + const handleRemove = () => { + console.log("=== REMOVING ASSIGNEE IMMEDIATELY ==="); + // TRIPLE state reset to ensure avatar disappears + setLocalAssignee(null); + onAssigneeChange([]); + // Force immediate re-render by updating key + setTimeout(() => setLocalAssignee(null), 0); + }; + + // IMMEDIATE SELECTION - Updates UI instantly + const handleSelect = (memberId: string) => { + const selectedMember = availableMembers.find(m => m.id === memberId); + console.log("=== SELECTING MEMBER IMMEDIATELY:", selectedMember, "==="); + + if (selectedMember) { + setLocalAssignee(selectedMember); // IMMEDIATE UI UPDATE + onAssigneeChange([memberId]); // Trigger parent update + } + }; + + const handleSelfAssign = () => { + if (user?.id) { + const userAsMember = { + id: user.id, + first_name: user.first_name || "You", + last_name: user.last_name || "", + image_url: user.image_url + }; + console.log("=== SELF ASSIGNING IMMEDIATELY:", userAsMember, "==="); + setLocalAssignee(userAsMember); // IMMEDIATE UI UPDATE + onAssigneeChange([user.id]); // Trigger parent update + } + }; + + return ( +
+
+ {/* Show current assignee with IMMEDIATE state */} + {localAssignee && ( +
+ {localAssignee.image_url ? ( + {`${localAssignee.first_name}'s + ) : ( +
+ {localAssignee.first_name[0]} +
+ )} +
+ ✕ +
+
+ )} + + {/* Add member dropdown */} + + + + + + + Assign Team Member + +
+ setSearchQuery(e.target.value)} + className="dark:bg-dark-200 focus:ring-customViolet-500 w-full rounded-md border px-3 py-1 text-sm focus:outline-none focus:ring-2" + /> +
+ + + + {isLoading ? ( +
+ Loading members... +
+ ) : filteredMembers.length === 0 ? ( +
+ {availableMembers.length === 0 ? "No members available" : "No members found"} +
+ ) : ( + filteredMembers.map((member) => ( + handleSelect(member.id)} + disabled={localAssignee?.id === member.id} + > + {member.image_url ? ( + {`${member.first_name}'s + ) : ( +
+ {member.first_name[0]} +
+ )} + + {member.first_name} {member.last_name} + +
+ )) + )} +
+
+ + {/* Self assign button */} + {user && (!localAssignee || localAssignee.id !== user.id) && ( + + )} +
+ +
+ ); +} + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ const TaskViewModal = ({ onComplete, }: { onComplete?: (taskId: string) => void; }) => { + // ============================================================================ + // HOOKS AND STATE + // ============================================================================ const { isOpen, onOpen, onClose, type, data } = useModal(); + const user = useUserStore((state) => state.user); + const { fetchBoardData } = useKanbanStore(); + + // Loading states const [isLoading, setIsLoading] = useState(false); const [updateLoading, setUpdateLoading] = useState(false); - const [assigneeLoading, setAssigneeLoading] = useState(false); + + // Component state + const [prLink, setPrLink] = useState(""); + const [supabase, setSupabase] = useState(null); + const [boardId, setBoardId] = useState(""); + + // Data states + const [skillCategory, setSkillCategory] = useState(null); + const [sidekickDetails, setSidekickDetails] = useState([]); + const [primaryAssignee, setPrimaryAssignee] = useState(null); + const [createdBy, setCreatedBy] = useState(null); + const [forceRefreshKey, setForceRefreshKey] = useState(""); + + // Derived state const isModalOpen = isOpen && type === "taskViewModal"; const task = data as Task | null; - const user = useUserStore((state) => state.user); - const canModifyTask = - user?.role_id === 1 || - user?.role_id === 5 || - user?.role_id === 4 || - user?.role_id === 10; + + // Permission checks + const canModifyTask = user?.role_id === 1 || user?.role_id === 5 || user?.role_id === 4 || user?.role_id === 10; const canMarkAsDone = user?.role_id === 1 || user?.role_id === 5; - const [prLink, setPrLink] = useState(task?.pr_link || ""); - const { fetchBoardData } = useKanbanStore(); + // ============================================================================ + // EFFECTS + // ============================================================================ + + // Initialize Supabase client + useEffect(() => { + const supabaseClient = createClientClientComponent(); + setSupabase(supabaseClient); + }, []); + + // Fetch board ID from task's column + useEffect(() => { + if (!supabase || !task?.kanban_column_id) { + setBoardId(""); + return; + } + + const fetchBoardId = async () => { + try { + console.log("=== FETCHING BOARD ID FOR COLUMN:", task.kanban_column_id, "==="); + const { data, error } = await supabase + .from("kanban_columns") + .select("board_id") + .eq("id", task.kanban_column_id) + .single(); + + if (error) { + console.error("=== ERROR FETCHING BOARD ID:", error, "==="); + setBoardId(""); + } else if (data?.board_id) { + console.log("=== FOUND BOARD ID:", data.board_id, "==="); + setBoardId(data.board_id); + } + } catch (err) { + console.error("=== EXCEPTION FETCHING BOARD ID:", err, "==="); + setBoardId(""); + } + }; + + fetchBoardId(); + }, [task?.kanban_column_id, supabase]); + + // Reset states when task changes - AGGRESSIVE RESET + useEffect(() => { + if (task?.id) { + console.log("=== TASK CHANGED, RESETTING STATES ==="); + setPrLink(task?.pr_link || ""); + setPrimaryAssignee(null); + setForceRefreshKey(Date.now().toString()); // Force component refresh + } + }, [task?.id]); + + // Fetch skill category + useEffect(() => { + if (!supabase || !task?.skill_category_id) return; + + const fetchSkillCategory = async () => { + const { data, error } = await supabase + .from("skill_category") + .select("id, name") + .eq("id", task.skill_category_id) + .single(); + if (!error && data) { + setSkillCategory(data as SkillCategory); + } + }; + fetchSkillCategory(); + }, [task?.skill_category_id, supabase]); + + // Fetch sidekick details + useEffect(() => { + if (!supabase || !task?.sidekick_ids?.length) return; + + const fetchSidekickDetails = async () => { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .in("id", task.sidekick_ids); + if (!error && data) { + setSidekickDetails(data as CodevMember[]); + } + }; + fetchSidekickDetails(); + }, [task?.sidekick_ids, supabase]); + + // Fetch primary assignee - AGGRESSIVE FETCHING + useEffect(() => { + if (!supabase || !task) return; + + const fetchPrimaryAssignee = async () => { + const assigneeId = task?.codev_id || task?.codev?.id; + + console.log("=== FETCHING PRIMARY ASSIGNEE, ID:", assigneeId, "==="); + + // Always reset first + setPrimaryAssignee(null); + + if (assigneeId) { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", assigneeId) + .single(); + + if (!error && data) { + console.log("=== FOUND ASSIGNEE DATA:", data, "==="); + setPrimaryAssignee(data as CodevMember); + } else { + console.log("=== NO ASSIGNEE FOUND, ERROR:", error, "==="); + } + } else if (task?.codev) { + console.log("=== USING TASK.CODEV DIRECTLY:", task.codev, "==="); + setPrimaryAssignee({ + id: task.codev.id, + first_name: task.codev.first_name, + last_name: task.codev.last_name, + image_url: task.codev.image_url, + }); + } else { + console.log("=== NO ASSIGNEE DATA AVAILABLE ==="); + } + }; + + fetchPrimaryAssignee(); + }, [task, supabase]); + + // Fetch created by + useEffect(() => { + if (!supabase || !task?.created_by) return; - // handle PR link update + const fetchCreatedBy = async () => { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", task.created_by) + .single(); + + if (!error && data) { + setCreatedBy(data as CodevMember); + } + }; + fetchCreatedBy(); + }, [task?.created_by, supabase]); + + // ============================================================================ + // EVENT HANDLERS + // ============================================================================ + const handleUpdate = async () => { if (!task) return; @@ -94,10 +574,8 @@ const TaskViewModal = ({ if (response.success) { toast.success("PR Link updated successfully"); - - // ✅ Manually update state so UI updates immediately setPrLink(prLink); - task.pr_link = prLink; + if (task) task.pr_link = prLink; } else { toast.error(response.error || "Failed to update PR Link"); } @@ -116,15 +594,11 @@ const TaskViewModal = ({ if (result.success) { toast.success("Task completed and points awarded!"); - // ✅ Manually update local state (UI update) if (onComplete) { onComplete(task.id); } - // ✅ Close modal before refreshing onClose(); - - // Refetch the board data await fetchBoardData(); } else { toast.error(result.error || "Failed to complete task"); @@ -137,189 +611,98 @@ const TaskViewModal = ({ setIsLoading(false); }; - // State for Skill Category, Sidekick Details, and Primary Assignee - const [skillCategory, setSkillCategory] = useState( - null, - ); - const [sidekickDetails, setSidekickDetails] = useState([]); - const [primaryAssignee, setPrimaryAssignee] = useState( - null, - ); - const [createdBy, setCreatedBy] = useState(null); - const [availableCodevs, setAvailableCodevs] = useState([]); - - const [supabase, setSupabase] = useState(null); - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - // Fetch available codevs for assignment - useEffect(() => { - if (!supabase) return; - - const fetchAvailableCodevs = async () => { - const { data, error } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .eq("status", "active") - .order("first_name"); - - if (!error && data) { - setAvailableCodevs(data as CodevMember[]); - } - }; - - fetchAvailableCodevs(); - }, [supabase]); - - // Handle assignee selection - const handleAssigneeSelect = async (codevId: string) => { + // ============================================================================ + // ULTRA-AGGRESSIVE ASSIGNEE CHANGE HANDLER with immediate UI updates + // ============================================================================ + const handleAssigneeChange = async (memberIds: string[]) => { if (!task || !supabase) return; - setAssigneeLoading(true); + const newAssigneeId = memberIds[0] || undefined; + console.log("=== ASSIGNMENT CHANGE:", { newAssigneeId, memberIds }, "==="); + + // IMMEDIATE UI UPDATES FIRST - for instant feedback + if (newAssigneeId) { + // Find member in available members and set immediately + const selectedMember = primaryAssignee; // Use current state temporarily + console.log("=== SETTING ASSIGNEE IMMEDIATELY ==="); + } else { + console.log("=== CLEARING ASSIGNEE IMMEDIATELY ==="); + setPrimaryAssignee(null); + } try { + // Update database const { error } = await supabase - .from("task") - .update({ codev_id: codevId }) + .from("tasks") + .update({ codev_id: newAssigneeId || null }) .eq("id", task.id); if (!error) { - toast.success("Assignee updated successfully"); - - // Update local state - const selectedCodev = availableCodevs.find(c => c.id === codevId); - if (selectedCodev) { - setPrimaryAssignee(selectedCodev); - // Update task object + if (newAssigneeId) { + // Fetch new assignee data and update state + const { data: assigneeData } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", newAssigneeId) + .single(); + + if (assigneeData) { + console.log("=== UPDATING TASK OBJECT WITH NEW ASSIGNEE:", assigneeData, "==="); + setPrimaryAssignee(assigneeData); + if (task) { + task.codev_id = newAssigneeId; + task.codev = assigneeData as any; + } + toast.success(`Task assigned to ${assigneeData.first_name} ${assigneeData.last_name}`); + } + } else { + console.log("=== REMOVING ASSIGNEE FROM TASK - FINAL ==="); + setPrimaryAssignee(null); if (task) { - task.codev_id = codevId; - task.codev = selectedCodev as any; + task.codev_id = undefined; + task.codev = undefined; } + toast.success("Task unassigned successfully"); } - // Refetch board data + // Force refresh key to trigger component re-render + setForceRefreshKey(`${Date.now()}-${Math.random()}`); + + // Refresh board data await fetchBoardData(); } else { + console.error("=== DATABASE ERROR:", error, "==="); toast.error("Failed to update assignee"); - } - } catch (error) { - console.error("Error updating assignee:", error); - toast.error("Failed to update assignee"); - } - - setAssigneeLoading(false); - }; - - // Set the skill category from the task - useEffect(() => { - if (!supabase) return; - - if (task?.skill_category_id) { - const fetchSkillCategory = async () => { - const { data, error } = await supabase - .from("skill_category") - .select("id, name") - .eq("id", task.skill_category_id) - .single(); - if (!error && data) { - setSkillCategory(data as SkillCategory); - } - }; - fetchSkillCategory(); - } - }, [task?.skill_category_id, supabase]); - - // Fetch sidekick details to display their images - useEffect(() => { - if (!supabase) return; - - const fetchSidekickDetails = async () => { - if (task?.sidekick_ids && task.sidekick_ids.length > 0) { - const { data, error } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .in("id", task.sidekick_ids); - if (!error && data) { - setSidekickDetails(data as CodevMember[]); - } - } - }; - fetchSidekickDetails(); - }, [task?.sidekick_ids, supabase, data]); - - useEffect(() => { - if (!supabase) return; - - const fetchPrimaryAssignee = async () => { - const assigneeId = task?.codev_id || task?.codev?.id; - setPrimaryAssignee(null); // Reset primary assignee - - if (assigneeId) { - const { data, error } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .eq("id", assigneeId) - .single(); - - if (!error && data) { - setPrimaryAssignee(data as CodevMember); + + // Revert UI changes on database error + if (task?.codev_id) { + const { data: revertData } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", task.codev_id) + .single(); + if (revertData) setPrimaryAssignee(revertData); } else { setPrimaryAssignee(null); } - } else if (task?.codev) { - // If we have the codev object directly, use that - setPrimaryAssignee({ - id: task.codev.id, - first_name: task.codev.first_name, - last_name: task.codev.last_name, - image_url: task.codev.image_url, - }); } - }; - - if (task) { - fetchPrimaryAssignee(); + } catch (error) { + console.error("=== ERROR UPDATING ASSIGNEE:", error, "==="); + toast.error("Failed to update assignee"); } - }, [task, supabase, data]); - - useEffect(() => { - if (!supabase) return; - - const fetchCreatedBy = async () => { - const createdById = task?.created_by; - setCreatedBy(null); // Reset created by - - if (createdById) { - const { data, error } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .eq("id", createdById) - .single(); - - if (!error && data) { - setCreatedBy(data as CodevMember); - } else { - setCreatedBy(null); - } - } - }; - fetchCreatedBy(); - }, [task?.created_by, supabase, data]); - - // Return previous PR link when leaving the input field empty - useEffect(() => { - setPrLink(task?.pr_link || ""); // Reset PR link when task changes - }, [task?.id]); // Runs when a new task is selected + }; + // Early return if (!isModalOpen) return null; + // ============================================================================ + // RENDER + // ============================================================================ return ( - +
- {/* Header with Title and Dropdown Menu */} + {/* Header */}
@@ -337,7 +720,6 @@ const TaskViewModal = ({ > Edit - {user?.role_id !== 4 && ( onOpen("taskDeleteModal", task)} @@ -351,23 +733,21 @@ const TaskViewModal = ({ )}
- {/* Task Details Grid */} + {/* Form Fields */}
{/* Task Title */}
{/* Points */}
-
- -
+ - - + - - {PRIORITY_LEVELS.map((level) => ( - - {level} - - ))} - + {PRIORITY_LEVELS.map((level) => ( + + {level} + + ))}
- {/* Difficulty (capitalized) */} + {/* Difficulty */}
- - + - - {DIFFICULTY_LEVELS.map((level) => ( - - {capitalize(level)} - - ))} - + {DIFFICULTY_LEVELS.map((level) => ( + + {capitalize(level)} + + ))}
@@ -440,7 +804,7 @@ const TaskViewModal = ({
{/* PR Link */} -
+
setPrLink(e.target.value)} onBlur={() => { if (!prLink.trim() && task?.pr_link) { - setPrLink(task.pr_link || ""); + setPrLink(task.pr_link); } }} className="text-grey-100 bg-light-900 dark:bg-dark-200 dark:text-light-900 focus:border-customBlue-500 border border-gray-300" - required placeholder="Enter PR Link..." /> @@ -638,7 +980,7 @@ const TaskViewModal = ({ variant="default" onClick={handleMarkAsDone} disabled={isLoading} - className="text-md bg-customBlue-100 hover:bg-customBlue-200 focus-visible:ring-customBlue-100 flex h-10 w-full items-center justify-center gap-2 whitespace-nowrap rounded-md px-6 py-1 text-white ring-offset-background transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:w-auto lg:text-lg" + className={BUTTON_STYLES.primary} > {isLoading && ( From b3283e7ac74dae4cf4f7cc8ce690c36979f23562 Mon Sep 17 00:00:00 2001 From: raineer Date: Wed, 17 Sep 2025 02:01:05 +0800 Subject: [PATCH 3/5] quick assignee selection --- .../[id]/_components/tasks/TaskViewModal.tsx | 146 +++++++++++++++--- 1 file changed, 125 insertions(+), 21 deletions(-) diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx index a45236041..6968e33bd 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx @@ -65,6 +65,7 @@ const TaskViewModal = ({ const { isOpen, onOpen, onClose, type, data } = useModal(); const [isLoading, setIsLoading] = useState(false); const [updateLoading, setUpdateLoading] = useState(false); + const [assigneeLoading, setAssigneeLoading] = useState(false); const isModalOpen = isOpen && type === "taskViewModal"; const task = data as Task | null; const user = useUserStore((state) => state.user); @@ -137,7 +138,6 @@ const TaskViewModal = ({ }; // State for Skill Category, Sidekick Details, and Primary Assignee - const [skillCategory, setSkillCategory] = useState( null, ); @@ -146,6 +146,7 @@ const TaskViewModal = ({ null, ); const [createdBy, setCreatedBy] = useState(null); + const [availableCodevs, setAvailableCodevs] = useState([]); const [supabase, setSupabase] = useState(null); useEffect(() => { @@ -153,6 +154,64 @@ const TaskViewModal = ({ setSupabase(supabaseClient); }, []); + // Fetch available codevs for assignment + useEffect(() => { + if (!supabase) return; + + const fetchAvailableCodevs = async () => { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("status", "active") + .order("first_name"); + + if (!error && data) { + setAvailableCodevs(data as CodevMember[]); + } + }; + + fetchAvailableCodevs(); + }, [supabase]); + + // Handle assignee selection + const handleAssigneeSelect = async (codevId: string) => { + if (!task || !supabase) return; + + setAssigneeLoading(true); + + try { + const { error } = await supabase + .from("task") + .update({ codev_id: codevId }) + .eq("id", task.id); + + if (!error) { + toast.success("Assignee updated successfully"); + + // Update local state + const selectedCodev = availableCodevs.find(c => c.id === codevId); + if (selectedCodev) { + setPrimaryAssignee(selectedCodev); + // Update task object + if (task) { + task.codev_id = codevId; + task.codev = selectedCodev as any; + } + } + + // Refetch board data + await fetchBoardData(); + } else { + toast.error("Failed to update assignee"); + } + } catch (error) { + console.error("Error updating assignee:", error); + toast.error("Failed to update assignee"); + } + + setAssigneeLoading(false); + }; + // Set the skill category from the task useEffect(() => { if (!supabase) return; @@ -428,27 +487,72 @@ const TaskViewModal = ({ )}
- {/* Primary Assignee */} + {/* Primary Assignee - MODIFIED SECTION */}
-
- {primaryAssignee && primaryAssignee.image_url ? ( - {`${primaryAssignee.first_name} - ) : ( - - )} - - {primaryAssignee && task - ? `${primaryAssignee.first_name} ${primaryAssignee.last_name}` - : "Unassigned"} - -
+ {primaryAssignee ? ( + // Show assigned member +
+ {primaryAssignee.image_url ? ( + {`${primaryAssignee.first_name} + ) : ( + + )} + + {`${primaryAssignee.first_name} ${primaryAssignee.last_name}`} + +
+ ) : ( + // Show assignee selection dropdown when unassigned +
+ {canModifyTask ? ( + + ) : ( +
+ + Unassigned +
+ )} + {assigneeLoading && ( + + )} +
+ )}
@@ -549,4 +653,4 @@ const TaskViewModal = ({ ); }; -export default TaskViewModal; +export default TaskViewModal; \ No newline at end of file From 19745784c701ff20adba6223c94c780f39c2d081 Mon Sep 17 00:00:00 2001 From: raineer Date: Wed, 17 Sep 2025 14:26:01 +0800 Subject: [PATCH 4/5] add members dropdown --- .../kanban_modals/KanbanAddModalMembers.tsx | 909 +++++++++++++++--- .../[id]/_components/tasks/TaskViewModal.tsx | 894 +++++++++++------ 2 files changed, 1381 insertions(+), 422 deletions(-) diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx index e391a6a8a..d351280cc 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/kanban_modals/KanbanAddModalMembers.tsx @@ -1,11 +1,31 @@ +"use client"; + import { useEffect, useState } from "react"; import Image from "next/image"; +import DefaultAvatar from "@/components/DefaultAvatar"; import { Button } from "@/components/ui/button"; -import { getCachedUser } from "@/lib/server/supabase-server-comp"; -import { IconPlus } from "@/public/assets/svgs"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useModal } from "@/hooks/use-modal"; +import { useUserStore } from "@/store/codev-store"; +import { useKanbanStore } from "@/store/kanban-store"; +import { SkillCategory, Task } from "@/types/home/codev"; import { createClientClientComponent } from "@/utils/supabase/client"; +import { Ellipsis, Loader2Icon } from "lucide-react"; +import toast from "react-hot-toast"; -import { cn } from "@codevs/ui"; import { DropdownMenu, DropdownMenuContent, @@ -14,9 +34,28 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@codevs/ui/dropdown-menu"; +import { Input } from "@codevs/ui/input"; +import { Label } from "@codevs/ui/label"; +import { IconPlus } from "@/public/assets/svgs"; + +import { completeTask, updateTaskPRLink, fetchAvailableMembers } from "../../actions"; +import DifficultyPointsTooltip, { + DIFFICULTY_LEVELS, +} from "../DifficultyPointsTooltip"; -import { fetchAvailableMembers } from "../../actions"; +// ============================================================================ +// CONSTANTS - Following DRY principle +// ============================================================================ +const PRIORITY_LEVELS = ["critical", "high", "medium", "low"]; +const BUTTON_STYLES = { + primary: "text-md bg-customBlue-100 hover:bg-customBlue-200 focus-visible:ring-customBlue-100 flex h-10 w-full items-center justify-center gap-2 whitespace-nowrap rounded-md px-6 py-1 text-white ring-offset-background transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:w-auto lg:text-lg", + secondary: "text-grey-100 bg-light-900 dark:bg-black-200 mt-4 w-full border-2 border-gray-300 py-4 text-black hover:bg-green-700 sm:w-auto" +}; + +// ============================================================================ +// INTERFACES - Following SOLID principle +// ============================================================================ interface CodevMember { id: string; first_name: string; @@ -24,155 +63,137 @@ interface CodevMember { image_url?: string | null; } -interface Props { - initialSelectedMembers?: string[]; // Array of member IDs - onMembersChange?: (memberIds: string[]) => void; - projectId: string; - /** Optional list of member IDs that should be disabled from selection */ - disabledMembers?: string[]; - /** If true, only one member can be selected */ - singleSelection?: boolean; -} - -export default function KanbanAddModalMembers({ - initialSelectedMembers = [], - onMembersChange, - projectId, - disabledMembers = [], - singleSelection = false, -}: Props) { - const [searchQuery, setSearchQuery] = useState(""); - const [selectedMemberIds, setSelectedMemberIds] = useState( - initialSelectedMembers, - ); - const [user, setUser] = useState(null); +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ +const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); +// ============================================================================ +// AGGRESSIVE ASSIGNEE SELECTOR - IMMEDIATE UI UPDATES +// ============================================================================ +function AssigneeSelector({ + primaryAssignee, + onAssigneeChange, + boardId, + user, + forceRefreshKey // Add this to force complete re-render when needed +}: { + primaryAssignee: CodevMember | null; + onAssigneeChange: (memberIds: string[]) => void; + boardId: string; + user: any; + forceRefreshKey?: string; +}) { const [availableMembers, setAvailableMembers] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [supabase, setSupabase] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [localAssignee, setLocalAssignee] = useState(primaryAssignee); - // useEffect(() => { - // const loadMembers = async () => { - // try { - // const members = await fetchAvailableMembers(projectId); - // setAvailableMembers(members); - // } catch (error) { - // console.error("Error loading members:", error); - // } finally { - // setIsLoading(false); - // } - // }; - - // loadMembers(); - // }, [projectId]); + // Sync with prop changes but prioritize local state for immediate feedback useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); + setLocalAssignee(primaryAssignee); + }, [primaryAssignee, forceRefreshKey]); + // Load members when boardId changes useEffect(() => { - if (!supabase) return; - + if (!boardId) { + setAvailableMembers([]); + setIsLoading(false); + return; + } + const loadMembers = async () => { + setIsLoading(true); try { - // console.log("Fetching members for project ID:", projectId); - const members = await fetchAvailableMembers(projectId); - // console.log("Fetched members:", members); // Debugging log - const user = await supabase.auth.getUser(); - // console.log("Current user:", user); // Debugging log - - if (Array.isArray(members)) { + console.log("=== LOADING MEMBERS FOR BOARD:", boardId, "==="); + const members = await fetchAvailableMembers(boardId); + console.log("=== FETCHED MEMBERS:", members, "==="); + + if (Array.isArray(members) && members.length > 0) { setAvailableMembers(members); + console.log("=== SET AVAILABLE MEMBERS:", members.length, "==="); } else { - console.error("Expected an array but got:", members); + console.warn("=== NO MEMBERS FOUND OR INVALID RESPONSE ==="); + setAvailableMembers([]); } - - setUser(user.data.user); } catch (error) { - console.error("Error loading members:", error); + console.error("=== ERROR LOADING MEMBERS:", error, "==="); + setAvailableMembers([]); } finally { setIsLoading(false); } }; - if (projectId) { - loadMembers(); - } - }, [projectId, supabase]); + loadMembers(); + }, [boardId]); - const selectedMembers = availableMembers.filter((member) => - selectedMemberIds.includes(member.id), + const filteredMembers = availableMembers.filter((member) => + `${member.first_name} ${member.last_name}` + .toLowerCase() + .includes(searchQuery.toLowerCase()), ); - const addMember = (memberId: string) => { - if (singleSelection) { - const newIds = [memberId]; - setSelectedMemberIds(newIds); - onMembersChange?.(newIds); - } else if (!selectedMemberIds.includes(memberId)) { - const newIds = [...selectedMemberIds, memberId]; - setSelectedMemberIds(newIds); - onMembersChange?.(newIds); - } + // IMMEDIATE REMOVAL - Updates UI instantly + const handleRemove = () => { + console.log("=== REMOVING ASSIGNEE IMMEDIATELY ==="); + setLocalAssignee(null); // IMMEDIATE UI UPDATE + onAssigneeChange([]); // Trigger parent update }; - const removeMember = (memberId: string) => { - const newSelectedIds = selectedMemberIds.filter((id) => id !== memberId); - setSelectedMemberIds(newSelectedIds); - onMembersChange?.(newSelectedIds); // Ensure this triggers parent update + // IMMEDIATE SELECTION - Updates UI instantly + const handleSelect = (memberId: string) => { + const selectedMember = availableMembers.find(m => m.id === memberId); + console.log("=== SELECTING MEMBER IMMEDIATELY:", selectedMember, "==="); + + if (selectedMember) { + setLocalAssignee(selectedMember); // IMMEDIATE UI UPDATE + onAssigneeChange([memberId]); // Trigger parent update + } }; const handleSelfAssign = () => { - if (user && !selectedMemberIds.includes(user.id)) { - const newSelectedIds = [...selectedMemberIds, user.id]; - setSelectedMemberIds(newSelectedIds); - onMembersChange?.(newSelectedIds); + if (user?.id) { + const userAsMember = { + id: user.id, + first_name: user.first_name || "You", + last_name: user.last_name || "", + image_url: user.image_url + }; + console.log("=== SELF ASSIGNING IMMEDIATELY:", userAsMember, "==="); + setLocalAssignee(userAsMember); // IMMEDIATE UI UPDATE + onAssigneeChange([user.id]); // Trigger parent update } }; - const filteredMembers = availableMembers.filter((member) => - `${member.first_name} ${member.last_name}` - .toLowerCase() - .includes(searchQuery.toLowerCase()), - ); - return (
- {/* */} -
- {selectedMembers.map((member) => ( + {/* Show current assignee with IMMEDIATE state */} + {localAssignee && (
removeMember(member.id)} - title={`${member.first_name} ${member.last_name}`} + onClick={handleRemove} + title={`${localAssignee.first_name} ${localAssignee.last_name} - Click to remove`} > - {member.image_url ? ( + {localAssignee.image_url ? ( {`${member.first_name}'s ) : (
- {member.first_name[0]} + {localAssignee.first_name[0]}
)}
✕
- ))} + )} + {/* Add member dropdown */} -
+ )} +
+ + {/* Debug info - remove in production */} +
+ Members loaded: {availableMembers.length} | Local assignee: {localAssignee?.first_name || 'None'}
); } + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ +const TaskViewModal = ({ + onComplete, +}: { + onComplete?: (taskId: string) => void; +}) => { + // ============================================================================ + // HOOKS AND STATE + // ============================================================================ + const { isOpen, onOpen, onClose, type, data } = useModal(); + const user = useUserStore((state) => state.user); + const { fetchBoardData } = useKanbanStore(); + + // Loading states + const [isLoading, setIsLoading] = useState(false); + const [updateLoading, setUpdateLoading] = useState(false); + + // Component state + const [prLink, setPrLink] = useState(""); + const [supabase, setSupabase] = useState(null); + const [boardId, setBoardId] = useState(""); + + // Data states + const [skillCategory, setSkillCategory] = useState(null); + const [sidekickDetails, setSidekickDetails] = useState([]); + const [primaryAssignee, setPrimaryAssignee] = useState(null); + const [createdBy, setCreatedBy] = useState(null); + const [forceRefreshKey, setForceRefreshKey] = useState(""); + + // Derived state + const isModalOpen = isOpen && type === "taskViewModal"; + const task = data as Task | null; + + // Permission checks + const canModifyTask = user?.role_id === 1 || user?.role_id === 5 || user?.role_id === 4 || user?.role_id === 10; + const canMarkAsDone = user?.role_id === 1 || user?.role_id === 5; + + // ============================================================================ + // EFFECTS + // ============================================================================ + + // Initialize Supabase client + useEffect(() => { + const supabaseClient = createClientClientComponent(); + setSupabase(supabaseClient); + }, []); + + // Fetch board ID from task's column + useEffect(() => { + if (!supabase || !task?.kanban_column_id) { + setBoardId(""); + return; + } + + const fetchBoardId = async () => { + try { + console.log("=== FETCHING BOARD ID FOR COLUMN:", task.kanban_column_id, "==="); + const { data, error } = await supabase + .from("kanban_columns") + .select("board_id") + .eq("id", task.kanban_column_id) + .single(); + + if (error) { + console.error("=== ERROR FETCHING BOARD ID:", error, "==="); + setBoardId(""); + } else if (data?.board_id) { + console.log("=== FOUND BOARD ID:", data.board_id, "==="); + setBoardId(data.board_id); + } + } catch (err) { + console.error("=== EXCEPTION FETCHING BOARD ID:", err, "==="); + setBoardId(""); + } + }; + + fetchBoardId(); + }, [task?.kanban_column_id, supabase]); + + // Reset states when task changes - AGGRESSIVE RESET + useEffect(() => { + if (task?.id) { + console.log("=== TASK CHANGED, RESETTING STATES ==="); + setPrLink(task?.pr_link || ""); + setPrimaryAssignee(null); + setForceRefreshKey(Date.now().toString()); // Force component refresh + } + }, [task?.id]); + + // Fetch skill category + useEffect(() => { + if (!supabase || !task?.skill_category_id) return; + + const fetchSkillCategory = async () => { + const { data, error } = await supabase + .from("skill_category") + .select("id, name") + .eq("id", task.skill_category_id) + .single(); + if (!error && data) { + setSkillCategory(data as SkillCategory); + } + }; + fetchSkillCategory(); + }, [task?.skill_category_id, supabase]); + + // Fetch sidekick details + useEffect(() => { + if (!supabase || !task?.sidekick_ids?.length) return; + + const fetchSidekickDetails = async () => { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .in("id", task.sidekick_ids); + if (!error && data) { + setSidekickDetails(data as CodevMember[]); + } + }; + fetchSidekickDetails(); + }, [task?.sidekick_ids, supabase]); + + // Fetch primary assignee - AGGRESSIVE FETCHING + useEffect(() => { + if (!supabase || !task) return; + + const fetchPrimaryAssignee = async () => { + const assigneeId = task?.codev_id || task?.codev?.id; + + console.log("=== FETCHING PRIMARY ASSIGNEE, ID:", assigneeId, "==="); + + // Always reset first + setPrimaryAssignee(null); + + if (assigneeId) { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", assigneeId) + .single(); + + if (!error && data) { + console.log("=== FOUND ASSIGNEE DATA:", data, "==="); + setPrimaryAssignee(data as CodevMember); + } else { + console.log("=== NO ASSIGNEE FOUND, ERROR:", error, "==="); + } + } else if (task?.codev) { + console.log("=== USING TASK.CODEV DIRECTLY:", task.codev, "==="); + setPrimaryAssignee({ + id: task.codev.id, + first_name: task.codev.first_name, + last_name: task.codev.last_name, + image_url: task.codev.image_url, + }); + } else { + console.log("=== NO ASSIGNEE DATA AVAILABLE ==="); + } + }; + + fetchPrimaryAssignee(); + }, [task, supabase]); + + // Fetch created by + useEffect(() => { + if (!supabase || !task?.created_by) return; + + const fetchCreatedBy = async () => { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", task.created_by) + .single(); + + if (!error && data) { + setCreatedBy(data as CodevMember); + } + }; + fetchCreatedBy(); + }, [task?.created_by, supabase]); + + // ============================================================================ + // EVENT HANDLERS + // ============================================================================ + + const handleUpdate = async () => { + if (!task) return; + + if (!prLink.trim()) { + toast.error("PR Link cannot be empty"); + return; + } + + setUpdateLoading(true); + + const response = await updateTaskPRLink(task.id, prLink); + + if (response.success) { + toast.success("PR Link updated successfully"); + setPrLink(prLink); + if (task) task.pr_link = prLink; + } else { + toast.error(response.error || "Failed to update PR Link"); + } + + setUpdateLoading(false); + }; + + const handleMarkAsDone = async () => { + if (!task) return; + + setIsLoading(true); + + try { + const result = await completeTask(task); + + if (result.success) { + toast.success("Task completed and points awarded!"); + + if (onComplete) { + onComplete(task.id); + } + + onClose(); + await fetchBoardData(); + } else { + toast.error(result.error || "Failed to complete task"); + } + } catch (error) { + console.error("Error completing task:", error); + toast.error("Failed to complete task"); + } + + setIsLoading(false); + }; + + // ============================================================================ + // AGGRESSIVE ASSIGNEE CHANGE HANDLER + // ============================================================================ + const handleAssigneeChange = async (memberIds: string[]) => { + if (!task || !supabase) return; + + const newAssigneeId = memberIds[0] || undefined; + console.log("=== ASSIGNMENT CHANGE:", { newAssigneeId, memberIds }, "==="); + + try { + // Update database + const { error } = await supabase + .from("tasks") + .update({ codev_id: newAssigneeId }) + .eq("id", task.id); + + if (!error) { + if (newAssigneeId) { + // Fetch new assignee data and update state + const { data: assigneeData } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", newAssigneeId) + .single(); + + if (assigneeData) { + console.log("=== UPDATING TASK OBJECT WITH NEW ASSIGNEE:", assigneeData, "==="); + setPrimaryAssignee(assigneeData); + if (task) { + task.codev_id = newAssigneeId; + task.codev = assigneeData as any; + } + toast.success(`Task assigned to ${assigneeData.first_name} ${assigneeData.last_name}`); + } + } else { + console.log("=== REMOVING ASSIGNEE FROM TASK ==="); + setPrimaryAssignee(null); + if (task) { + task.codev_id = undefined; + task.codev = undefined; + } + toast.success("Task unassigned successfully"); + } + + // Force refresh key to trigger component re-render + setForceRefreshKey(Date.now().toString()); + + // Refresh board data + await fetchBoardData(); + } else { + console.error("=== DATABASE ERROR:", error, "==="); + toast.error("Failed to update assignee"); + } + } catch (error) { + console.error("=== ERROR UPDATING ASSIGNEE:", error, "==="); + toast.error("Failed to update assignee"); + } + }; + + // Early return + if (!isModalOpen) return null; + + // ============================================================================ + // RENDER + // ============================================================================ + return ( + + +
+ {/* Header */} +
+ + + {task?.title} + + + {canModifyTask && ( + + + + + + onOpen("taskEditModal", task)} + > + Edit + + {user?.role_id !== 4 && ( + onOpen("taskDeleteModal", task)} + className="text-red-500 focus:text-red-500" + > + Delete + + )} + + + )} +
+ + {/* Form Fields */} +
+ {/* Task Title */} +
+ + +
+ + {/* Points */} +
+ + +
+ + {/* Priority */} +
+ + +
+ + {/* Difficulty */} +
+
+ + +
+ +
+ + {/* Task Type */} +
+ + +
+ + {/* PR Link */} +
+ +
+ setPrLink(e.target.value)} + onBlur={() => { + if (!prLink.trim() && task?.pr_link) { + setPrLink(task.pr_link); + } + }} + className="text-grey-100 bg-light-900 dark:bg-dark-200 dark:text-light-900 focus:border-customBlue-500 border border-gray-300" + placeholder="Enter PR Link..." + /> + +
+
+ + {/* Skill Category */} +
+ + {skillCategory ? ( +
+ {skillCategory.name} +
+ ) : ( +
None assigned
+ )} +
+ + {/* ================================================================ */} + {/* WORKING PRIMARY ASSIGNEE SELECTOR */} + {/* ================================================================ */} +
+ + {canModifyTask ? ( +
+ {boardId ? ( + + ) : ( +
Loading board...
+ )} +
+ ) : primaryAssignee ? ( +
+ {primaryAssignee.image_url ? ( + {`${primaryAssignee.first_name} + ) : ( + + )} + + {`${primaryAssignee.first_name} ${primaryAssignee.last_name}`} + +
+ ) : ( +
+ + Unassigned +
+ )} +
+
+ + {/* Team Members (Sidekicks) */} + {task?.sidekick_ids && task.sidekick_ids.length > 0 && ( +
+ +
+ {sidekickDetails.length > 0 + ? sidekickDetails.map((member) => ( +
+ {member.image_url ? ( + {`${member.first_name} + ) : ( + + )} +
+ )) + : task.sidekick_ids.map((memberId) => ( +
+ +
+ ))} +
+
+ )} + + {/* Description */} +
+ +
+
+ + {/* Created By */} +
+ +
+ {createdBy?.image_url ? ( + {`${createdBy.first_name} + ) : ( + + )} + + {createdBy + ? `${createdBy.first_name} ${createdBy.last_name}` + : "Unknown"} + +
+
+ + {/* Footer Actions */} + + + {canMarkAsDone && task?.pr_link && ( + + )} + +
+ +
+ ); +}; + +export default TaskViewModal; \ No newline at end of file diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx index 6968e33bd..4365323c6 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskViewModal.tsx @@ -1,9 +1,7 @@ "use client"; -import { resourceLimits } from "worker_threads"; import { useEffect, useState } from "react"; import Image from "next/image"; -import { useRouter } from "next/navigation"; import DefaultAvatar from "@/components/DefaultAvatar"; import { Button } from "@/components/ui/button"; import { @@ -16,7 +14,6 @@ import { import { Select, SelectContent, - SelectGroup, SelectItem, SelectTrigger, SelectValue, @@ -26,7 +23,6 @@ import { useUserStore } from "@/store/codev-store"; import { useKanbanStore } from "@/store/kanban-store"; import { SkillCategory, Task } from "@/types/home/codev"; import { createClientClientComponent } from "@/utils/supabase/client"; -import { set } from "date-fns"; import { Ellipsis, Loader2Icon } from "lucide-react"; import toast from "react-hot-toast"; @@ -34,17 +30,124 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@codevs/ui/dropdown-menu"; import { Input } from "@codevs/ui/input"; import { Label } from "@codevs/ui/label"; -import { Textarea } from "@codevs/ui/textarea"; +import { IconPlus } from "@/public/assets/svgs"; import { completeTask, updateTaskPRLink } from "../../actions"; import DifficultyPointsTooltip, { DIFFICULTY_LEVELS, } from "../DifficultyPointsTooltip"; +// ============================================================================ +// WORKING fetchAvailableMembers implementation with debug logging +// ============================================================================ +const fetchAvailableMembers = async (boardId: string): Promise => { + try { + const supabase = createClientClientComponent(); + + if (!supabase) { + console.error("=== Supabase client not initialized ==="); + return []; + } + + console.log("=== STARTING fetchAvailableMembers for boardId:", boardId, "==="); + + // 1. Get the project_id from the kanban_boards table + const { data: board, error: boardError } = await supabase + .from("kanban_boards") + .select("project_id") + .eq("id", boardId) + .single(); + + if (boardError) { + console.error("=== Board fetch error:", boardError, "==="); + return []; + } + + if (!board?.project_id) { + console.error("=== No project associated with board:", boardId, "==="); + return []; + } + + console.log("=== Found project_id:", board.project_id, "==="); + + // 2. Get all project members from project_members table + const { data: projectMembers, error: projectMembersError } = await supabase + .from("project_members") + .select("codev_id, role") + .eq("project_id", board.project_id); + + if (projectMembersError) { + console.error("=== Project members fetch error:", projectMembersError, "==="); + return []; + } + + if (!projectMembers?.length) { + console.error("=== No project members found for project:", board.project_id, "==="); + return []; + } + + console.log("=== Found project members:", projectMembers, "==="); + + // 3. Collect ALL member IDs (including team leader) + const allMemberIds = projectMembers.map(member => member.codev_id); + console.log("=== All member IDs:", allMemberIds, "==="); + + // 4. Fetch ALL members' details from the codev table + const { data: codevMembers, error: codevError } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url, availability_status") + .in("id", allMemberIds); + + if (codevError) { + console.error("=== Codev members fetch error:", codevError, "==="); + return []; + } + + if (!codevMembers?.length) { + console.error("=== No codev details found for member IDs:", allMemberIds, "==="); + return []; + } + + console.log("=== Found codev members:", codevMembers, "==="); + + // 5. Filter available members and format for return + const availableMembers = codevMembers + .filter(member => member.availability_status === true) + .map(member => ({ + id: member.id, + first_name: member.first_name, + last_name: member.last_name, + image_url: member.image_url, + })); + + console.log("=== Final available members:", availableMembers, "==="); + return availableMembers; + + } catch (error) { + console.error("=== CRITICAL ERROR in fetchAvailableMembers:", error, "==="); + return []; + } +}; + +// ============================================================================ +// CONSTANTS - Following DRY principle +// ============================================================================ +const PRIORITY_LEVELS = ["critical", "high", "medium", "low"]; + +const BUTTON_STYLES = { + primary: "text-md bg-customBlue-100 hover:bg-customBlue-200 focus-visible:ring-customBlue-100 flex h-10 w-full items-center justify-center gap-2 whitespace-nowrap rounded-md px-6 py-1 text-white ring-offset-background transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:w-auto lg:text-lg", + secondary: "text-grey-100 bg-light-900 dark:bg-black-200 mt-4 w-full border-2 border-gray-300 py-4 text-black hover:bg-green-700 sm:w-auto" +}; + +// ============================================================================ +// INTERFACES - Following SOLID principle +// ============================================================================ interface CodevMember { id: string; first_name: string; @@ -52,34 +155,411 @@ interface CodevMember { image_url?: string | null; } -const PRIORITY_LEVELS = ["critical", "high", "medium", "low"]; - -// Utility function to capitalize the first letter +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); +// ============================================================================ +// AGGRESSIVE ASSIGNEE SELECTOR - IMMEDIATE UI UPDATES +// ============================================================================ +function AssigneeSelector({ + primaryAssignee, + onAssigneeChange, + boardId, + user, + forceRefreshKey // Add this to force complete re-render when needed +}: { + primaryAssignee: CodevMember | null; + onAssigneeChange: (memberIds: string[]) => void; + boardId: string; + user: any; + forceRefreshKey?: string; +}) { + const [availableMembers, setAvailableMembers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [localAssignee, setLocalAssignee] = useState(primaryAssignee); + + // Sync with prop changes but prioritize local state for immediate feedback + useEffect(() => { + setLocalAssignee(primaryAssignee); + }, [primaryAssignee, forceRefreshKey]); + + // Load members when boardId changes + useEffect(() => { + if (!boardId) { + setAvailableMembers([]); + setIsLoading(false); + return; + } + + const loadMembers = async () => { + setIsLoading(true); + try { + console.log("=== LOADING MEMBERS FOR BOARD:", boardId, "==="); + const members = await fetchAvailableMembers(boardId); + console.log("=== FETCHED MEMBERS:", members, "==="); + + if (Array.isArray(members) && members.length > 0) { + setAvailableMembers(members); + console.log("=== SET AVAILABLE MEMBERS:", members.length, "==="); + } else { + console.warn("=== NO MEMBERS FOUND OR INVALID RESPONSE ==="); + setAvailableMembers([]); + } + } catch (error) { + console.error("=== ERROR LOADING MEMBERS:", error, "==="); + setAvailableMembers([]); + } finally { + setIsLoading(false); + } + }; + + loadMembers(); + }, [boardId]); + + const filteredMembers = availableMembers.filter((member) => + `${member.first_name} ${member.last_name}` + .toLowerCase() + .includes(searchQuery.toLowerCase()), + ); + + // IMMEDIATE REMOVAL - Updates UI instantly with FORCED state reset + const handleRemove = () => { + console.log("=== REMOVING ASSIGNEE IMMEDIATELY ==="); + // TRIPLE state reset to ensure avatar disappears + setLocalAssignee(null); + onAssigneeChange([]); + // Force immediate re-render by updating key + setTimeout(() => setLocalAssignee(null), 0); + }; + + // IMMEDIATE SELECTION - Updates UI instantly + const handleSelect = (memberId: string) => { + const selectedMember = availableMembers.find(m => m.id === memberId); + console.log("=== SELECTING MEMBER IMMEDIATELY:", selectedMember, "==="); + + if (selectedMember) { + setLocalAssignee(selectedMember); // IMMEDIATE UI UPDATE + onAssigneeChange([memberId]); // Trigger parent update + } + }; + + const handleSelfAssign = () => { + if (user?.id) { + const userAsMember = { + id: user.id, + first_name: user.first_name || "You", + last_name: user.last_name || "", + image_url: user.image_url + }; + console.log("=== SELF ASSIGNING IMMEDIATELY:", userAsMember, "==="); + setLocalAssignee(userAsMember); // IMMEDIATE UI UPDATE + onAssigneeChange([user.id]); // Trigger parent update + } + }; + + return ( +
+
+ {/* Show current assignee with IMMEDIATE state */} + {localAssignee && ( +
+ {localAssignee.image_url ? ( + {`${localAssignee.first_name}'s + ) : ( +
+ {localAssignee.first_name[0]} +
+ )} +
+ ✕ +
+
+ )} + + {/* Add member dropdown */} + + + + + + + Assign Team Member + +
+ setSearchQuery(e.target.value)} + className="dark:bg-dark-200 focus:ring-customViolet-500 w-full rounded-md border px-3 py-1 text-sm focus:outline-none focus:ring-2" + /> +
+ + + + {isLoading ? ( +
+ Loading members... +
+ ) : filteredMembers.length === 0 ? ( +
+ {availableMembers.length === 0 ? "No members available" : "No members found"} +
+ ) : ( + filteredMembers.map((member) => ( + handleSelect(member.id)} + disabled={localAssignee?.id === member.id} + > + {member.image_url ? ( + {`${member.first_name}'s + ) : ( +
+ {member.first_name[0]} +
+ )} + + {member.first_name} {member.last_name} + +
+ )) + )} +
+
+ + {/* Self assign button */} + {user && (!localAssignee || localAssignee.id !== user.id) && ( + + )} +
+ +
+ ); +} + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ const TaskViewModal = ({ onComplete, }: { onComplete?: (taskId: string) => void; }) => { + // ============================================================================ + // HOOKS AND STATE + // ============================================================================ const { isOpen, onOpen, onClose, type, data } = useModal(); + const user = useUserStore((state) => state.user); + const { fetchBoardData } = useKanbanStore(); + + // Loading states const [isLoading, setIsLoading] = useState(false); const [updateLoading, setUpdateLoading] = useState(false); - const [assigneeLoading, setAssigneeLoading] = useState(false); + + // Component state + const [prLink, setPrLink] = useState(""); + const [supabase, setSupabase] = useState(null); + const [boardId, setBoardId] = useState(""); + + // Data states + const [skillCategory, setSkillCategory] = useState(null); + const [sidekickDetails, setSidekickDetails] = useState([]); + const [primaryAssignee, setPrimaryAssignee] = useState(null); + const [createdBy, setCreatedBy] = useState(null); + const [forceRefreshKey, setForceRefreshKey] = useState(""); + + // Derived state const isModalOpen = isOpen && type === "taskViewModal"; const task = data as Task | null; - const user = useUserStore((state) => state.user); - const canModifyTask = - user?.role_id === 1 || - user?.role_id === 5 || - user?.role_id === 4 || - user?.role_id === 10; + + // Permission checks + const canModifyTask = user?.role_id === 1 || user?.role_id === 5 || user?.role_id === 4 || user?.role_id === 10; const canMarkAsDone = user?.role_id === 1 || user?.role_id === 5; - const [prLink, setPrLink] = useState(task?.pr_link || ""); - const { fetchBoardData } = useKanbanStore(); + // ============================================================================ + // EFFECTS + // ============================================================================ + + // Initialize Supabase client + useEffect(() => { + const supabaseClient = createClientClientComponent(); + setSupabase(supabaseClient); + }, []); + + // Fetch board ID from task's column + useEffect(() => { + if (!supabase || !task?.kanban_column_id) { + setBoardId(""); + return; + } + + const fetchBoardId = async () => { + try { + console.log("=== FETCHING BOARD ID FOR COLUMN:", task.kanban_column_id, "==="); + const { data, error } = await supabase + .from("kanban_columns") + .select("board_id") + .eq("id", task.kanban_column_id) + .single(); + + if (error) { + console.error("=== ERROR FETCHING BOARD ID:", error, "==="); + setBoardId(""); + } else if (data?.board_id) { + console.log("=== FOUND BOARD ID:", data.board_id, "==="); + setBoardId(data.board_id); + } + } catch (err) { + console.error("=== EXCEPTION FETCHING BOARD ID:", err, "==="); + setBoardId(""); + } + }; + + fetchBoardId(); + }, [task?.kanban_column_id, supabase]); + + // Reset states when task changes - AGGRESSIVE RESET + useEffect(() => { + if (task?.id) { + console.log("=== TASK CHANGED, RESETTING STATES ==="); + setPrLink(task?.pr_link || ""); + setPrimaryAssignee(null); + setForceRefreshKey(Date.now().toString()); // Force component refresh + } + }, [task?.id]); + + // Fetch skill category + useEffect(() => { + if (!supabase || !task?.skill_category_id) return; + + const fetchSkillCategory = async () => { + const { data, error } = await supabase + .from("skill_category") + .select("id, name") + .eq("id", task.skill_category_id) + .single(); + if (!error && data) { + setSkillCategory(data as SkillCategory); + } + }; + fetchSkillCategory(); + }, [task?.skill_category_id, supabase]); + + // Fetch sidekick details + useEffect(() => { + if (!supabase || !task?.sidekick_ids?.length) return; + + const fetchSidekickDetails = async () => { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .in("id", task.sidekick_ids); + if (!error && data) { + setSidekickDetails(data as CodevMember[]); + } + }; + fetchSidekickDetails(); + }, [task?.sidekick_ids, supabase]); + + // Fetch primary assignee - AGGRESSIVE FETCHING + useEffect(() => { + if (!supabase || !task) return; + + const fetchPrimaryAssignee = async () => { + const assigneeId = task?.codev_id || task?.codev?.id; + + console.log("=== FETCHING PRIMARY ASSIGNEE, ID:", assigneeId, "==="); + + // Always reset first + setPrimaryAssignee(null); + + if (assigneeId) { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", assigneeId) + .single(); + + if (!error && data) { + console.log("=== FOUND ASSIGNEE DATA:", data, "==="); + setPrimaryAssignee(data as CodevMember); + } else { + console.log("=== NO ASSIGNEE FOUND, ERROR:", error, "==="); + } + } else if (task?.codev) { + console.log("=== USING TASK.CODEV DIRECTLY:", task.codev, "==="); + setPrimaryAssignee({ + id: task.codev.id, + first_name: task.codev.first_name, + last_name: task.codev.last_name, + image_url: task.codev.image_url, + }); + } else { + console.log("=== NO ASSIGNEE DATA AVAILABLE ==="); + } + }; + + fetchPrimaryAssignee(); + }, [task, supabase]); + + // Fetch created by + useEffect(() => { + if (!supabase || !task?.created_by) return; - // handle PR link update + const fetchCreatedBy = async () => { + const { data, error } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", task.created_by) + .single(); + + if (!error && data) { + setCreatedBy(data as CodevMember); + } + }; + fetchCreatedBy(); + }, [task?.created_by, supabase]); + + // ============================================================================ + // EVENT HANDLERS + // ============================================================================ + const handleUpdate = async () => { if (!task) return; @@ -94,10 +574,8 @@ const TaskViewModal = ({ if (response.success) { toast.success("PR Link updated successfully"); - - // ✅ Manually update state so UI updates immediately setPrLink(prLink); - task.pr_link = prLink; + if (task) task.pr_link = prLink; } else { toast.error(response.error || "Failed to update PR Link"); } @@ -116,15 +594,11 @@ const TaskViewModal = ({ if (result.success) { toast.success("Task completed and points awarded!"); - // ✅ Manually update local state (UI update) if (onComplete) { onComplete(task.id); } - // ✅ Close modal before refreshing onClose(); - - // Refetch the board data await fetchBoardData(); } else { toast.error(result.error || "Failed to complete task"); @@ -137,189 +611,98 @@ const TaskViewModal = ({ setIsLoading(false); }; - // State for Skill Category, Sidekick Details, and Primary Assignee - const [skillCategory, setSkillCategory] = useState( - null, - ); - const [sidekickDetails, setSidekickDetails] = useState([]); - const [primaryAssignee, setPrimaryAssignee] = useState( - null, - ); - const [createdBy, setCreatedBy] = useState(null); - const [availableCodevs, setAvailableCodevs] = useState([]); - - const [supabase, setSupabase] = useState(null); - useEffect(() => { - const supabaseClient = createClientClientComponent(); - setSupabase(supabaseClient); - }, []); - - // Fetch available codevs for assignment - useEffect(() => { - if (!supabase) return; - - const fetchAvailableCodevs = async () => { - const { data, error } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .eq("status", "active") - .order("first_name"); - - if (!error && data) { - setAvailableCodevs(data as CodevMember[]); - } - }; - - fetchAvailableCodevs(); - }, [supabase]); - - // Handle assignee selection - const handleAssigneeSelect = async (codevId: string) => { + // ============================================================================ + // ULTRA-AGGRESSIVE ASSIGNEE CHANGE HANDLER with immediate UI updates + // ============================================================================ + const handleAssigneeChange = async (memberIds: string[]) => { if (!task || !supabase) return; - setAssigneeLoading(true); + const newAssigneeId = memberIds[0] || undefined; + console.log("=== ASSIGNMENT CHANGE:", { newAssigneeId, memberIds }, "==="); + + // IMMEDIATE UI UPDATES FIRST - for instant feedback + if (newAssigneeId) { + // Find member in available members and set immediately + const selectedMember = primaryAssignee; // Use current state temporarily + console.log("=== SETTING ASSIGNEE IMMEDIATELY ==="); + } else { + console.log("=== CLEARING ASSIGNEE IMMEDIATELY ==="); + setPrimaryAssignee(null); + } try { + // Update database const { error } = await supabase - .from("task") - .update({ codev_id: codevId }) + .from("tasks") + .update({ codev_id: newAssigneeId || null }) .eq("id", task.id); if (!error) { - toast.success("Assignee updated successfully"); - - // Update local state - const selectedCodev = availableCodevs.find(c => c.id === codevId); - if (selectedCodev) { - setPrimaryAssignee(selectedCodev); - // Update task object + if (newAssigneeId) { + // Fetch new assignee data and update state + const { data: assigneeData } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", newAssigneeId) + .single(); + + if (assigneeData) { + console.log("=== UPDATING TASK OBJECT WITH NEW ASSIGNEE:", assigneeData, "==="); + setPrimaryAssignee(assigneeData); + if (task) { + task.codev_id = newAssigneeId; + task.codev = assigneeData as any; + } + toast.success(`Task assigned to ${assigneeData.first_name} ${assigneeData.last_name}`); + } + } else { + console.log("=== REMOVING ASSIGNEE FROM TASK - FINAL ==="); + setPrimaryAssignee(null); if (task) { - task.codev_id = codevId; - task.codev = selectedCodev as any; + task.codev_id = undefined; + task.codev = undefined; } + toast.success("Task unassigned successfully"); } - // Refetch board data + // Force refresh key to trigger component re-render + setForceRefreshKey(`${Date.now()}-${Math.random()}`); + + // Refresh board data await fetchBoardData(); } else { + console.error("=== DATABASE ERROR:", error, "==="); toast.error("Failed to update assignee"); - } - } catch (error) { - console.error("Error updating assignee:", error); - toast.error("Failed to update assignee"); - } - - setAssigneeLoading(false); - }; - - // Set the skill category from the task - useEffect(() => { - if (!supabase) return; - - if (task?.skill_category_id) { - const fetchSkillCategory = async () => { - const { data, error } = await supabase - .from("skill_category") - .select("id, name") - .eq("id", task.skill_category_id) - .single(); - if (!error && data) { - setSkillCategory(data as SkillCategory); - } - }; - fetchSkillCategory(); - } - }, [task?.skill_category_id, supabase]); - - // Fetch sidekick details to display their images - useEffect(() => { - if (!supabase) return; - - const fetchSidekickDetails = async () => { - if (task?.sidekick_ids && task.sidekick_ids.length > 0) { - const { data, error } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .in("id", task.sidekick_ids); - if (!error && data) { - setSidekickDetails(data as CodevMember[]); - } - } - }; - fetchSidekickDetails(); - }, [task?.sidekick_ids, supabase, data]); - - useEffect(() => { - if (!supabase) return; - - const fetchPrimaryAssignee = async () => { - const assigneeId = task?.codev_id || task?.codev?.id; - setPrimaryAssignee(null); // Reset primary assignee - - if (assigneeId) { - const { data, error } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .eq("id", assigneeId) - .single(); - - if (!error && data) { - setPrimaryAssignee(data as CodevMember); + + // Revert UI changes on database error + if (task?.codev_id) { + const { data: revertData } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", task.codev_id) + .single(); + if (revertData) setPrimaryAssignee(revertData); } else { setPrimaryAssignee(null); } - } else if (task?.codev) { - // If we have the codev object directly, use that - setPrimaryAssignee({ - id: task.codev.id, - first_name: task.codev.first_name, - last_name: task.codev.last_name, - image_url: task.codev.image_url, - }); } - }; - - if (task) { - fetchPrimaryAssignee(); + } catch (error) { + console.error("=== ERROR UPDATING ASSIGNEE:", error, "==="); + toast.error("Failed to update assignee"); } - }, [task, supabase, data]); - - useEffect(() => { - if (!supabase) return; - - const fetchCreatedBy = async () => { - const createdById = task?.created_by; - setCreatedBy(null); // Reset created by - - if (createdById) { - const { data, error } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .eq("id", createdById) - .single(); - - if (!error && data) { - setCreatedBy(data as CodevMember); - } else { - setCreatedBy(null); - } - } - }; - fetchCreatedBy(); - }, [task?.created_by, supabase, data]); - - // Return previous PR link when leaving the input field empty - useEffect(() => { - setPrLink(task?.pr_link || ""); // Reset PR link when task changes - }, [task?.id]); // Runs when a new task is selected + }; + // Early return if (!isModalOpen) return null; + // ============================================================================ + // RENDER + // ============================================================================ return ( - +
- {/* Header with Title and Dropdown Menu */} + {/* Header */}
@@ -337,7 +720,6 @@ const TaskViewModal = ({ > Edit - {user?.role_id !== 4 && ( onOpen("taskDeleteModal", task)} @@ -351,23 +733,21 @@ const TaskViewModal = ({ )}
- {/* Task Details Grid */} + {/* Form Fields */}
{/* Task Title */}
{/* Points */}
-
- -
+ - - + - - {PRIORITY_LEVELS.map((level) => ( - - {level} - - ))} - + {PRIORITY_LEVELS.map((level) => ( + + {level} + + ))}
- {/* Difficulty (capitalized) */} + {/* Difficulty */}
- - + - - {DIFFICULTY_LEVELS.map((level) => ( - - {capitalize(level)} - - ))} - + {DIFFICULTY_LEVELS.map((level) => ( + + {capitalize(level)} + + ))}
@@ -440,7 +804,7 @@ const TaskViewModal = ({
{/* PR Link */} -
+
setPrLink(e.target.value)} onBlur={() => { if (!prLink.trim() && task?.pr_link) { - setPrLink(task.pr_link || ""); + setPrLink(task.pr_link); } }} className="text-grey-100 bg-light-900 dark:bg-dark-200 dark:text-light-900 focus:border-customBlue-500 border border-gray-300" - required placeholder="Enter PR Link..." /> @@ -638,7 +980,7 @@ const TaskViewModal = ({ variant="default" onClick={handleMarkAsDone} disabled={isLoading} - className="text-md bg-customBlue-100 hover:bg-customBlue-200 focus-visible:ring-customBlue-100 flex h-10 w-full items-center justify-center gap-2 whitespace-nowrap rounded-md px-6 py-1 text-white ring-offset-background transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:w-auto lg:text-lg" + className={BUTTON_STYLES.primary} > {isLoading && ( From 425755ae3f7c4f251ba86f0abb236a901aa2c0e0 Mon Sep 17 00:00:00 2001 From: raineer Date: Fri, 19 Sep 2025 11:31:31 +0800 Subject: [PATCH 5/5] The save changes functionality prevents data loss and provides clear feedback to users about pending modifications. --- .../[id]/_components/tasks/TaskAddModal.tsx | 362 ++++++++++- .../[id]/_components/tasks/TaskEditModal.tsx | 265 +++++++- .../[id]/_components/tasks/TaskViewModal.tsx | 585 +++++++++--------- .../home/kanban/[projectId]/[id]/actions.ts | 349 ++++------- 4 files changed, 983 insertions(+), 578 deletions(-) diff --git a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskAddModal.tsx b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskAddModal.tsx index 9d3dfb76d..cf9c50ab9 100644 --- a/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskAddModal.tsx +++ b/apps/codebility/app/home/kanban/[projectId]/[id]/_components/tasks/TaskAddModal.tsx @@ -2,6 +2,7 @@ import type { SkillCategory } from "@/types/home/codev"; import { useEffect, useState, useTransition } from "react"; +import Image from "next/image"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -19,27 +20,290 @@ import { SelectValue, } from "@/components/ui/select"; import { useModal } from "@/hooks/use-modal"; +import { IconPlus } from "@/public/assets/svgs"; import { useUserStore } from "@/store/codev-store"; import { useKanbanStore } from "@/store/kanban-store"; import { createClientClientComponent } from "@/utils/supabase/client"; import { Loader2Icon } from "lucide-react"; import toast from "react-hot-toast"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@codevs/ui/dropdown-menu"; import { Input } from "@codevs/ui/input"; import { Label } from "@codevs/ui/label"; -import { Textarea } from "@codevs/ui/textarea"; import { createNewTask } from "../../actions"; import DifficultyPointsTooltip, { DIFFICULTY_LEVELS, DIFFICULTY_POINTS, } from "../DifficultyPointsTooltip"; -import KanbanAddModalMembers from "../kanban_modals/KanbanAddModalMembers"; import KanbanRichTextEditor from "../kanban_modals/KanbanRichTextEditor"; const PRIORITY_LEVELS = ["critical", "high", "medium", "low"]; const TASK_TYPES = ["FEATURE", "BUG", "IMPROVEMENT", "DOCUMENTATION"]; +interface CodevMember { + id: string; + first_name: string; + last_name: string; + image_url?: string | null; +} + +// Fetch available members function - from TaskViewModal +const fetchAvailableMembers = async ( + projectId: string, +): Promise => { + try { + const supabase = createClientClientComponent(); + if (!supabase) return []; + + const { data: projectMembers, error: projectMembersError } = await supabase + .from("project_members") + .select("codev_id, role") + .eq("project_id", projectId); + + if (projectMembersError || !projectMembers?.length) return []; + + const allMemberIds = projectMembers.map((member) => member.codev_id); + + const { data: codevMembers, error: codevError } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url, availability_status") + .in("id", allMemberIds); + + if (codevError || !codevMembers?.length) return []; + + return codevMembers + .filter((member) => member.availability_status === true) + .map((member) => ({ + id: member.id, + first_name: member.first_name, + last_name: member.last_name, + image_url: member.image_url, + })); + } catch (error) { + console.error("Error in fetchAvailableMembers:", error); + return []; + } +}; + +function MemberSelector({ + selectedMemberIds = [], + onMembersChange, + projectId, + disabledMembers = [], + singleSelection = false, + label = "Team Members", +}: { + selectedMemberIds?: string[]; + onMembersChange?: (memberIds: string[]) => void; + projectId: string; + disabledMembers?: string[]; + singleSelection?: boolean; + label?: string; +}) { + const [availableMembers, setAvailableMembers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [supabase, setSupabase] = useState(null); + const [user, setUser] = useState(null); + + useEffect(() => { + const supabaseClient = createClientClientComponent(); + setSupabase(supabaseClient); + }, []); + + useEffect(() => { + if (!projectId || !supabase) { + setAvailableMembers([]); + setIsLoading(false); + return; + } + + const loadMembers = async () => { + setIsLoading(true); + try { + const members = await fetchAvailableMembers(projectId); + if (Array.isArray(members) && members.length > 0) { + setAvailableMembers(members); + } else { + setAvailableMembers([]); + } + const { data: userData } = await supabase.auth.getUser(); + setUser(userData?.user); + } catch (error) { + console.error("Error loading members:", error); + setAvailableMembers([]); + } finally { + setIsLoading(false); + } + }; + + loadMembers(); + }, [projectId, supabase]); + + const selectedMembers = availableMembers.filter((member) => + selectedMemberIds.includes(member.id), + ); + + const filteredMembers = availableMembers.filter( + (member) => + `${member.first_name} ${member.last_name}` + .toLowerCase() + .includes(searchQuery.toLowerCase()) && + !disabledMembers.includes(member.id), + ); + + const addMember = (memberId: string) => { + let newIds: string[]; + if (singleSelection) { + newIds = [memberId]; + } else if (!selectedMemberIds.includes(memberId)) { + newIds = [...selectedMemberIds, memberId]; + } else { + return; + } + onMembersChange?.(newIds); + }; + + const removeMember = (memberId: string) => { + const newIds = selectedMemberIds.filter((id) => id !== memberId); + onMembersChange?.(newIds); + }; + + const handleSelfAssign = () => { + if (user?.id && !selectedMemberIds.includes(user.id)) { + const newIds = singleSelection + ? [user.id] + : [...selectedMemberIds, user.id]; + onMembersChange?.(newIds); + } + }; + + return ( +
+
+ {selectedMembers.map((member) => ( +
removeMember(member.id)} + title={`${member.first_name} ${member.last_name} - Click to remove`} + > + {member.image_url ? ( + {`${member.first_name}'s + ) : ( +
+ {member.first_name[0]} +
+ )} +
+ ✕ +
+
+ ))} + + + + + + + + Assign {label} + +
+ setSearchQuery(e.target.value)} + className="w-full rounded-md border px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800" + /> +
+ + + + {isLoading ? ( +
+ Loading members... +
+ ) : filteredMembers.length === 0 ? ( +
+ {availableMembers.length === 0 + ? "No members available" + : "No members found"} +
+ ) : ( + filteredMembers.map((member) => ( + addMember(member.id)} + disabled={selectedMemberIds.includes(member.id)} + > + {member.image_url ? ( + {`${member.first_name}'s + ) : ( +
+ {member.first_name[0]} +
+ )} + + {member.first_name} {member.last_name} + + {selectedMemberIds.includes(member.id) && ( + ✓ + )} +
+ )) + )} +
+
+ + {user && (!singleSelection || selectedMemberIds.length === 0) && ( + + )} +
+
+ ); +} + const TaskAddModal = () => { const { isOpen, onClose, type, data } = useModal(); const isModalOpen = isOpen && type === "taskAddModal"; @@ -311,54 +575,84 @@ const TaskAddModal = () => {
-
-
-
- - setMainAssignee(ids[0] || "")} - projectId={data?.projectId} - /> -
- -
- - +
+ + {data?.projectId ? ( + { + setMainAssignee(memberIds[0] || ""); + }} + projectId={data.projectId} + singleSelection={true} + label="Primary Assignee" /> -
+ ) : ( +
Loading project...
+ )}
-
- +
+ + +
+ + + + + + Assign {label} + +
+ setSearchQuery(e.target.value)} + className="w-full rounded-md border px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800" + /> +
+ + + + {isLoading ? ( +
+ Loading members... +
+ ) : filteredMembers.length === 0 ? ( +
+ {availableMembers.length === 0 + ? "No members available" + : "No members found"} +
+ ) : ( + filteredMembers.map((member) => ( + addMember(member.id)} + disabled={selectedMemberIds.includes(member.id)} + > + {member.image_url ? ( + {`${member.first_name}'s + ) : ( +
+ {member.first_name[0]} +
+ )} + + {member.first_name} {member.last_name} + + {selectedMemberIds.includes(member.id) && ( + ✓ + )} +
+ )) + )} +
+ + + {user && (!singleSelection || selectedMemberIds.length === 0) && ( + + )} +
+
+ ); +} + const TaskEditModal = () => { const { isOpen, onClose, type, data } = useModal(); const isModalOpen = isOpen && type === "taskEditModal"; @@ -181,7 +411,6 @@ const TaskEditModal = () => { if (!taskData.title) throw new Error("Title is required"); if (!taskData.skill_category_id) throw new Error("Skill category is required"); - // if (!taskData.codev_id) throw new Error("Primary assignee is required"); const descriptionToSave = !taskData.description || @@ -192,7 +421,6 @@ const TaskEditModal = () => { const formData = new FormData(); - // Explicitly set each field formData.append("title", taskData.title); formData.append("description", descriptionToSave); formData.append("priority", taskData.priority || ""); @@ -203,18 +431,15 @@ const TaskEditModal = () => { formData.append("skill_category_id", taskData.skill_category_id); formData.append("codev_id", taskData.codev_id ?? "null"); - // Handle sidekick_ids specifically if (taskData.sidekick_ids && taskData.sidekick_ids.length > 0) { formData.append("sidekick_ids", taskData.sidekick_ids.join(",")); } else { - // Explicitly set empty array if no sidekicks formData.append("sidekick_ids", ""); } const response = await updateTask(formData, data.id); if (response.success) { toast.success("Task updated successfully."); - // Refetch the board data await fetchBoardData(); onClose(); } else { @@ -243,7 +468,6 @@ const TaskEditModal = () => {
- {/* Task Title */}
- {/* Points */}
- {/* Priority */}
- {/* Difficulty */}
@@ -342,7 +563,6 @@ const TaskEditModal = () => {
- {/* Task Type */}
- {/* Skill Category */}
- {/* Primary Assignee */}
- { handleInputChange("codev_id", memberIds[0] || ""); }} projectId={boardData.project_id} - initialSelectedMembers={[data.codev?.id].filter(Boolean)} + singleSelection={true} + label="Primary Assignee" />
- {/* Sidekick Helpers */}
- { handleInputChange("sidekick_ids", memberIds); }} projectId={boardData.project_id} - disabledMembers={[data.codev?.id].filter(Boolean)} + disabledMembers={taskData.codev_id ? [taskData.codev_id] : []} + singleSelection={false} + label="Sidekick Helpers" />
- {/* Description */}
); } -// ============================================================================ -// MAIN COMPONENT -// ============================================================================ const TaskViewModal = ({ onComplete, }: { onComplete?: (taskId: string) => void; }) => { - // ============================================================================ - // HOOKS AND STATE - // ============================================================================ const { isOpen, onOpen, onClose, type, data } = useModal(); const user = useUserStore((state) => state.user); const { fetchBoardData } = useKanbanStore(); - - // Loading states + const [isLoading, setIsLoading] = useState(false); const [updateLoading, setUpdateLoading] = useState(false); - - // Component state + const [prLink, setPrLink] = useState(""); + const [originalPrLink, setOriginalPrLink] = useState(""); const [supabase, setSupabase] = useState(null); const [boardId, setBoardId] = useState(""); - - // Data states - const [skillCategory, setSkillCategory] = useState(null); + + const [skillCategory, setSkillCategory] = useState( + null, + ); const [sidekickDetails, setSidekickDetails] = useState([]); - const [primaryAssignee, setPrimaryAssignee] = useState(null); + const [primaryAssignee, setPrimaryAssignee] = useState( + null, + ); const [createdBy, setCreatedBy] = useState(null); const [forceRefreshKey, setForceRefreshKey] = useState(""); - - // Derived state + + // ✅ FIXED: Enhanced state management for assignee changes + const [manualSaveChanges, setManualSaveChanges] = useState(false); + const [isSavingChanges, setIsSavingChanges] = useState(false); + const [pendingAssigneeId, setPendingAssigneeId] = useState(undefined); + const isModalOpen = isOpen && type === "taskViewModal"; const task = data as Task | null; - - // Permission checks - const canModifyTask = user?.role_id === 1 || user?.role_id === 5 || user?.role_id === 4 || user?.role_id === 10; + + const hasPrLinkChanges = prLink.trim() !== originalPrLink; + const hasUnsavedChanges = manualSaveChanges || hasPrLinkChanges; + + const canModifyTask = + user?.role_id === 1 || + user?.role_id === 5 || + user?.role_id === 4 || + user?.role_id === 10; const canMarkAsDone = user?.role_id === 1 || user?.role_id === 5; - // ============================================================================ - // EFFECTS - // ============================================================================ - - // Initialize Supabase client useEffect(() => { const supabaseClient = createClientClientComponent(); setSupabase(supabaseClient); }, []); - // Fetch board ID from task's column useEffect(() => { if (!supabase || !task?.kanban_column_id) { setBoardId(""); return; } - + const fetchBoardId = async () => { try { - console.log("=== FETCHING BOARD ID FOR COLUMN:", task.kanban_column_id, "==="); const { data, error } = await supabase .from("kanban_columns") .select("board_id") .eq("id", task.kanban_column_id) .single(); - + if (error) { - console.error("=== ERROR FETCHING BOARD ID:", error, "==="); setBoardId(""); } else if (data?.board_id) { - console.log("=== FOUND BOARD ID:", data.board_id, "==="); setBoardId(data.board_id); } } catch (err) { - console.error("=== EXCEPTION FETCHING BOARD ID:", err, "==="); + console.error("Exception fetching board ID:", err); setBoardId(""); } }; - + fetchBoardId(); }, [task?.kanban_column_id, supabase]); - // Reset states when task changes - AGGRESSIVE RESET useEffect(() => { if (task?.id) { - console.log("=== TASK CHANGED, RESETTING STATES ==="); - setPrLink(task?.pr_link || ""); - setPrimaryAssignee(null); - setForceRefreshKey(Date.now().toString()); // Force component refresh + const taskPrLink = task?.pr_link || ""; + setPrLink(taskPrLink); + setOriginalPrLink(taskPrLink); + setPrimaryAssignee(null); // Reset to null first + setManualSaveChanges(false); + setPendingAssigneeId(undefined); + setForceRefreshKey(Date.now().toString()); + + // Force reset the AssigneeSelector local state by resetting primaryAssignee + setTimeout(() => { + // Fetch actual assignee from task after reset + if (task.codev_id || task?.codev?.id) { + const assigneeId = task.codev_id || (task.codev?.id || ""); + if (supabase && assigneeId) { + supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", assigneeId) + .single() + .then(({ data, error }) => { + if (!error && data) { + setPrimaryAssignee(data as CodevMember); + } + }); + } + } + }, 0); } - }, [task?.id]); + }, [task?.id, supabase]); - // Fetch skill category useEffect(() => { if (!supabase || !task?.skill_category_id) return; @@ -481,7 +423,6 @@ const TaskViewModal = ({ fetchSkillCategory(); }, [task?.skill_category_id, supabase]); - // Fetch sidekick details useEffect(() => { if (!supabase || !task?.sidekick_ids?.length) return; @@ -497,16 +438,12 @@ const TaskViewModal = ({ fetchSidekickDetails(); }, [task?.sidekick_ids, supabase]); - // Fetch primary assignee - AGGRESSIVE FETCHING useEffect(() => { if (!supabase || !task) return; const fetchPrimaryAssignee = async () => { const assigneeId = task?.codev_id || task?.codev?.id; - - console.log("=== FETCHING PRIMARY ASSIGNEE, ID:", assigneeId, "==="); - - // Always reset first + setPrimaryAssignee(null); if (assigneeId) { @@ -517,28 +454,21 @@ const TaskViewModal = ({ .single(); if (!error && data) { - console.log("=== FOUND ASSIGNEE DATA:", data, "==="); setPrimaryAssignee(data as CodevMember); - } else { - console.log("=== NO ASSIGNEE FOUND, ERROR:", error, "==="); } } else if (task?.codev) { - console.log("=== USING TASK.CODEV DIRECTLY:", task.codev, "==="); setPrimaryAssignee({ id: task.codev.id, first_name: task.codev.first_name, last_name: task.codev.last_name, image_url: task.codev.image_url, }); - } else { - console.log("=== NO ASSIGNEE DATA AVAILABLE ==="); } }; fetchPrimaryAssignee(); }, [task, supabase]); - // Fetch created by useEffect(() => { if (!supabase || !task?.created_by) return; @@ -556,10 +486,6 @@ const TaskViewModal = ({ fetchCreatedBy(); }, [task?.created_by, supabase]); - // ============================================================================ - // EVENT HANDLERS - // ============================================================================ - const handleUpdate = async () => { if (!task) return; @@ -574,7 +500,7 @@ const TaskViewModal = ({ if (response.success) { toast.success("PR Link updated successfully"); - setPrLink(prLink); + setOriginalPrLink(prLink); if (task) task.pr_link = prLink; } else { toast.error(response.error || "Failed to update PR Link"); @@ -611,98 +537,135 @@ const TaskViewModal = ({ setIsLoading(false); }; - // ============================================================================ - // ULTRA-AGGRESSIVE ASSIGNEE CHANGE HANDLER with immediate UI updates - // ============================================================================ const handleAssigneeChange = async (memberIds: string[]) => { if (!task || !supabase) return; - const newAssigneeId = memberIds[0] || undefined; - console.log("=== ASSIGNMENT CHANGE:", { newAssigneeId, memberIds }, "==="); + const newAssigneeId = memberIds.length > 0 ? memberIds[0] : undefined; + + // Set manual save state to show Save Changes button + setManualSaveChanges(true); + setPendingAssigneeId(newAssigneeId); - // IMMEDIATE UI UPDATES FIRST - for instant feedback if (newAssigneeId) { - // Find member in available members and set immediately - const selectedMember = primaryAssignee; // Use current state temporarily - console.log("=== SETTING ASSIGNEE IMMEDIATELY ==="); + // Fetch assignee data for UI display only + const { data: assigneeData } = await supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", newAssigneeId) + .single(); + + if (assigneeData) { + setPrimaryAssignee(assigneeData); + } } else { - console.log("=== CLEARING ASSIGNEE IMMEDIATELY ==="); + // Handle removal case setPrimaryAssignee(null); } - try { - // Update database - const { error } = await supabase - .from("tasks") - .update({ codev_id: newAssigneeId || null }) - .eq("id", task.id); - - if (!error) { - if (newAssigneeId) { - // Fetch new assignee data and update state - const { data: assigneeData } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .eq("id", newAssigneeId) - .single(); - - if (assigneeData) { - console.log("=== UPDATING TASK OBJECT WITH NEW ASSIGNEE:", assigneeData, "==="); - setPrimaryAssignee(assigneeData); - if (task) { - task.codev_id = newAssigneeId; - task.codev = assigneeData as any; - } - toast.success(`Task assigned to ${assigneeData.first_name} ${assigneeData.last_name}`); + setForceRefreshKey(`${Date.now()}-${Math.random()}`); + }; + + // Reset state when modal closes without saving + const handleClose = () => { + if (hasUnsavedChanges) { + // Reset all unsaved changes + setManualSaveChanges(false); + setPendingAssigneeId(undefined); + setPrLink(originalPrLink); + + // Force complete reset by setting primary assignee to null first + setPrimaryAssignee(null); + + // Then set a unique force refresh key to reset AssigneeSelector + setForceRefreshKey(`close-reset-${Date.now()}-${Math.random()}`); + + // Reset to original task assignee after a brief delay + setTimeout(() => { + if (task?.codev_id || task?.codev?.id) { + const assigneeId = task.codev_id || (task.codev?.id || ""); + if (supabase && assigneeId) { + supabase + .from("codev") + .select("id, first_name, last_name, image_url") + .eq("id", assigneeId) + .single() + .then(({ data, error }) => { + if (!error && data) { + setPrimaryAssignee(data as CodevMember); + } + }); } } else { - console.log("=== REMOVING ASSIGNEE FROM TASK - FINAL ==="); + // Ensure it stays null for unassigned tasks setPrimaryAssignee(null); - if (task) { - task.codev_id = undefined; - task.codev = undefined; - } - toast.success("Task unassigned successfully"); } + }, 50); + } + onClose(); + }; - // Force refresh key to trigger component re-render - setForceRefreshKey(`${Date.now()}-${Math.random()}`); - - // Refresh board data - await fetchBoardData(); - } else { - console.error("=== DATABASE ERROR:", error, "==="); - toast.error("Failed to update assignee"); - - // Revert UI changes on database error - if (task?.codev_id) { - const { data: revertData } = await supabase - .from("codev") - .select("id, first_name, last_name, image_url") - .eq("id", task.codev_id) - .single(); - if (revertData) setPrimaryAssignee(revertData); + // ✅ FIXED: Enhanced handleSaveChanges with assignee removal support + const handleSaveChanges = async () => { + if (!task || !supabase) return; + + setIsSavingChanges(true); + + try { + // Save PR Link changes + if (hasPrLinkChanges) { + await handleUpdate(); + } + + // Save assignee changes (including removal) + if (manualSaveChanges) { + const { error } = await supabase + .from("tasks") + .update({ codev_id: pendingAssigneeId || null }) // Convert undefined to null for database + .eq("id", task.id); + + if (!error) { + // Update task object after successful save + if (task) { + task.codev_id = pendingAssigneeId; + if (pendingAssigneeId && primaryAssignee) { + task.codev = primaryAssignee as any; + } else { + task.codev = undefined; + } + } + + if (pendingAssigneeId && primaryAssignee) { + toast.success( + `Task assigned to ${primaryAssignee.first_name} ${primaryAssignee.last_name}`, + ); + } else { + toast.success("Task assignee removed"); + } } else { - setPrimaryAssignee(null); + toast.error("Failed to update assignee"); + throw new Error("Assignee update failed"); } } + + await fetchBoardData(); + setManualSaveChanges(false); + setPendingAssigneeId(undefined); + setOriginalPrLink(prLink); + toast.success("All changes saved successfully"); } catch (error) { - console.error("=== ERROR UPDATING ASSIGNEE:", error, "==="); - toast.error("Failed to update assignee"); + console.error("Error saving changes:", error); + toast.error("Failed to save changes"); + } finally { + setIsSavingChanges(false); } }; - // Early return if (!isModalOpen) return null; - // ============================================================================ - // RENDER - // ============================================================================ return (
- {/* Header */}
@@ -733,9 +696,7 @@ const TaskViewModal = ({ )}
- {/* Form Fields */}
- {/* Task Title */}
- {/* Points */}
- {/* Priority */}
- {/* Difficulty */}
@@ -785,7 +747,11 @@ const TaskViewModal = ({ {DIFFICULTY_LEVELS.map((level) => ( - + {capitalize(level)} ))} @@ -793,7 +759,6 @@ const TaskViewModal = ({
- {/* Task Type */}
- {/* PR Link */}
@@ -815,34 +779,29 @@ const TaskViewModal = ({ setPrLink(task.pr_link); } }} - className="text-grey-100 bg-light-900 dark:bg-dark-200 dark:text-light-900 focus:border-customBlue-500 border border-gray-300" + className="text-grey-100 bg-light-900 dark:bg-dark-200 dark:text-light-900 border border-gray-300 focus:border-blue-500" placeholder="Enter PR Link..." /> - + {hasPrLinkChanges && ( + + )}
- {/* Skill Category */}
{skillCategory ? ( -
+
{skillCategory.name}
) : ( @@ -850,12 +809,10 @@ const TaskViewModal = ({ )}
- {/* ================================================================ */} - {/* WORKING PRIMARY ASSIGNEE SELECTOR */} - {/* ================================================================ */}
- {canModifyTask ? ( + {canModifyTask && !primaryAssignee ? ( + // Only show AssigneeSelector for UNASSIGNED tasks
{boardId ? ( ) : ( -
Loading board...
+
+ Loading board... +
)}
) : primaryAssignee ? ( + // Read-only display for ASSIGNED tasks
{primaryAssignee.image_url ? (
) : ( + // Read-only display for users without modify permissions
Unassigned @@ -896,7 +857,6 @@ const TaskViewModal = ({
- {/* Team Members (Sidekicks) */} {task?.sidekick_ids && task.sidekick_ids.length > 0 && (
@@ -932,7 +892,6 @@ const TaskViewModal = ({
)} - {/* Description */}
- {/* Created By */}
@@ -966,21 +924,82 @@ const TaskViewModal = ({
- {/* Footer Actions */} - + {/* VIEWING MODE: Show Close when just viewing (no changes) */} + {!hasUnsavedChanges && ( + + )} + + {/* EDITING MODE: Show Cancel + Save Changes when has changes */} + {hasUnsavedChanges && ( + <> + + + + + )} + + {/* Mark as Done button - separate logic */} {canMarkAsDone && task?.pr_link && (