diff --git a/src/pages/taskmanager/components/task-detail/TaskDetailHeader.tsx b/src/pages/taskmanager/components/task-detail/TaskDetailHeader.tsx index d7f6452..f0fb6dc 100644 --- a/src/pages/taskmanager/components/task-detail/TaskDetailHeader.tsx +++ b/src/pages/taskmanager/components/task-detail/TaskDetailHeader.tsx @@ -1,21 +1,61 @@ +import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; import { ArrowDown2, CloseCircle } from "iconsax-react"; import { type FC } from "react"; import TrashWithConfrim from "../../../../components/TrashWithConfrim"; +import { clx } from "../../../../helpers/utils"; +import type { Column } from "../../types"; type Props = { - statusLabel: string; + columns: Column[]; + selectedPhaseId: string; + onPhaseChange: (phaseId: string) => void; + isChangingPhase?: boolean; onClose: () => void; onDelete?: () => void; isDeleting?: boolean; }; -const TaskDetailHeader: FC = ({ statusLabel, onClose, onDelete, isDeleting }) => { +const TaskDetailHeader: FC = ({ + columns, + selectedPhaseId, + onPhaseChange, + isChangingPhase, + onClose, + onDelete, + isDeleting, +}) => { + const selectedColumn = columns.find((column) => column.id === selectedPhaseId); + return (
- + + + {selectedColumn?.title ?? ""} + + + + + {columns.map((column) => ( + + ))} + +
{onDelete ? ( diff --git a/src/pages/taskmanager/components/task-detail/TaskDetailModal.tsx b/src/pages/taskmanager/components/task-detail/TaskDetailModal.tsx index 96b6f23..9c06cd9 100644 --- a/src/pages/taskmanager/components/task-detail/TaskDetailModal.tsx +++ b/src/pages/taskmanager/components/task-detail/TaskDetailModal.tsx @@ -4,9 +4,9 @@ import { toast } from "react-toastify"; import DefaulModal from "../../../../components/DefaulModal"; import PageLoading from "../../../../components/PageLoading"; import { ErrorType } from "../../../../helpers/types"; -import { useDeleteTask, useGetTaskDetail, useUpdateTask } from "../../task/hooks/useTaskData"; +import { useChangeTaskPhase, useDeleteTask, useGetTaskDetail, useUpdateTask } from "../../task/hooks/useTaskData"; import type { TaskDetailType } from "../../task/types/TaskTypes"; -import type { Task } from "../../types"; +import type { Column, Task } from "../../types"; import AttachmentList from "./attachment/List"; import CheckLists from "./checklist/List"; import TaskDetailDescription from "./TaskDetailDescription"; @@ -17,11 +17,13 @@ import type { TaskDetailTab } from "./types"; type Props = { open: boolean; task: Task | null; - statusLabel: string; + columns: Column[]; + projectId: string; onClose: () => void; + onTaskPhaseChanged?: (taskId: string, taskPhaseId: string) => void; }; -const TaskDetailModal: FC = ({ open, task, statusLabel, onClose }) => { +const TaskDetailModal: FC = ({ open, task, columns, projectId, onClose, onTaskPhaseChanged }) => { const { t } = useTranslation("global"); const [activeTab, setActiveTab] = useState(null); const [title, setTitle] = useState(""); @@ -31,6 +33,7 @@ const TaskDetailModal: FC = ({ open, task, statusLabel, onClose }) => { const getTaskDetail = useGetTaskDetail(task?.id ?? "", open && !!task?.id); const updateTask = useUpdateTask(); + const changeTaskPhase = useChangeTaskPhase(); const deleteTask = useDeleteTask(); const rawTaskDetail = getTaskDetail.data?.data?.task ?? getTaskDetail.data?.data; @@ -61,12 +64,16 @@ const TaskDetailModal: FC = ({ open, task, statusLabel, onClose }) => { initialDescriptionRef.current = taskDetail.description ?? ""; }, [taskDetail]); + if (!task) return null; + + const selectedPhaseId = taskDetail?.taskPhaseId ?? task.columnId; + const handleTabChange = (tab: TaskDetailTab) => { setActiveTab((prev) => (prev === tab ? null : tab)); }; const handleDelete = () => { - if (!task?.id) return; + if (!task.id) return; deleteTask.mutate( { id: task.id, projectId: taskDetail?.projectId ?? "" }, @@ -83,8 +90,6 @@ const TaskDetailModal: FC = ({ open, task, statusLabel, onClose }) => { }; const saveField = (field: "title" | "description", value: string) => { - if (!task?.id) return; - const initialValue = field === "title" ? initialTitleRef.current : initialDescriptionRef.current; if (value === initialValue) return; @@ -111,7 +116,22 @@ const TaskDetailModal: FC = ({ open, task, statusLabel, onClose }) => { ); }; - if (!task) return null; + const handlePhaseChange = (taskPhaseId: string) => { + if (taskPhaseId === selectedPhaseId) return; + + changeTaskPhase.mutate( + { id: task.id, params: { taskPhaseId }, projectId: taskDetail?.projectId ?? projectId }, + { + onSuccess: () => { + onTaskPhaseChanged?.(task.id, taskPhaseId); + toast.success(t("success")); + }, + onError: (error: ErrorType) => { + toast.error(error.response?.data?.error.message[0]); + }, + }, + ); + }; return ( @@ -120,7 +140,10 @@ const TaskDetailModal: FC = ({ open, task, statusLabel, onClose }) => { ) : ( <> { const queryClient = useQueryClient(); @@ -16,6 +16,18 @@ export const useCreateTaskPhase = () => { }); }; +export const useUpdateTaskPhase = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, params }: { id: string; params: UpdateTaskPhaseType; projectId: string }) => + api.updateTaskPhase(id, params), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] }); + }, + }); +}; + export const useDeleteTaskPhase = () => { const queryClient = useQueryClient(); diff --git a/src/pages/taskmanager/task-phase/service/TaskPhaseService.ts b/src/pages/taskmanager/task-phase/service/TaskPhaseService.ts index 5e3683b..9c01097 100644 --- a/src/pages/taskmanager/task-phase/service/TaskPhaseService.ts +++ b/src/pages/taskmanager/task-phase/service/TaskPhaseService.ts @@ -1,11 +1,16 @@ import axios from "../../../../config/axios"; -import { CreateTaskPhaseType } from "../types/TaskPhaseTypes"; +import { CreateTaskPhaseType, UpdateTaskPhaseType } from "../types/TaskPhaseTypes"; export const createTaskPhase = async (params: CreateTaskPhaseType) => { const { data } = await axios.post(`/task-manager/task-phases`, params); return data; }; +export const updateTaskPhase = async (id: string, params: UpdateTaskPhaseType) => { + const { data } = await axios.patch(`/task-manager/task-phases/${id}`, params); + return data; +}; + export const deleteTaskPhase = async (id: string) => { const { data } = await axios.delete(`/task-manager/task-phases/${id}`); return data; diff --git a/src/pages/taskmanager/task-phase/types/TaskPhaseTypes.ts b/src/pages/taskmanager/task-phase/types/TaskPhaseTypes.ts index 316841b..38d9d26 100644 --- a/src/pages/taskmanager/task-phase/types/TaskPhaseTypes.ts +++ b/src/pages/taskmanager/task-phase/types/TaskPhaseTypes.ts @@ -5,6 +5,10 @@ export type CreateTaskPhaseType = { projectId: string; }; +export type UpdateTaskPhaseType = { + order: number; +}; + export type TaskPhaseItemType = { id: string; name: string; diff --git a/src/pages/taskmanager/task/hooks/useTaskData.ts b/src/pages/taskmanager/task/hooks/useTaskData.ts index 50b6911..dba1e7e 100644 --- a/src/pages/taskmanager/task/hooks/useTaskData.ts +++ b/src/pages/taskmanager/task/hooks/useTaskData.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as api from "../service/TaskService"; import { + ChangeTaskPhaseType, CreateCheckListItemType, CreateTaskAttachmentType, CreateTaskRemarkType, @@ -40,6 +41,20 @@ export const useUpdateTask = () => { }); }; +export const useChangeTaskPhase = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, params }: { id: string; params: ChangeTaskPhaseType; projectId: string }) => + api.changeTaskPhase(id, params), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ["task-detail", variables.id] }); + queryClient.invalidateQueries({ queryKey: ["project", variables.projectId] }); + queryClient.invalidateQueries({ queryKey: ["project"] }); + }, + }); +}; + export const useDeleteTask = () => { const queryClient = useQueryClient(); diff --git a/src/pages/taskmanager/task/service/TaskService.ts b/src/pages/taskmanager/task/service/TaskService.ts index 6066723..78fb239 100644 --- a/src/pages/taskmanager/task/service/TaskService.ts +++ b/src/pages/taskmanager/task/service/TaskService.ts @@ -1,5 +1,6 @@ import axios from "../../../../config/axios"; import { + ChangeTaskPhaseType, CreateCheckListItemResponse, CreateCheckListItemType, CreateTaskAttachmentType, @@ -27,6 +28,11 @@ export const updateTask = async (id: string, params: UpdateTaskType) => { return data; }; +export const changeTaskPhase = async (id: string, params: ChangeTaskPhaseType) => { + const { data } = await axios.patch(`/task-manager/tasks/${id}/change-phase`, params); + return data; +}; + export const deleteTask = async (id: string) => { const { data } = await axios.delete(`/task-manager/tasks/${id}`); return data; diff --git a/src/pages/taskmanager/task/types/TaskTypes.ts b/src/pages/taskmanager/task/types/TaskTypes.ts index 544760a..486942b 100644 --- a/src/pages/taskmanager/task/types/TaskTypes.ts +++ b/src/pages/taskmanager/task/types/TaskTypes.ts @@ -107,6 +107,10 @@ export type TaskDetailType = { attachments?: TaskAttachmentType[]; }; +export type ChangeTaskPhaseType = { + taskPhaseId: string; +}; + export type UpdateTaskType = Partial< Pick > & { diff --git a/src/pages/taskmanager/utils/reorderColumns.ts b/src/pages/taskmanager/utils/reorderColumns.ts index 4306bbb..1b52f46 100644 --- a/src/pages/taskmanager/utils/reorderColumns.ts +++ b/src/pages/taskmanager/utils/reorderColumns.ts @@ -15,5 +15,5 @@ export const reorderColumns = ( const result = [...columns]; const [removed] = result.splice(oldIndex, 1); result.splice(newIndex, 0, removed); - return result; + return result.map((column, index) => ({ ...column, order: index + 1 })); }; diff --git a/src/pages/taskmanager/workspace.tsx b/src/pages/taskmanager/workspace.tsx index 1a84f5c..38c39f6 100644 --- a/src/pages/taskmanager/workspace.tsx +++ b/src/pages/taskmanager/workspace.tsx @@ -1,11 +1,15 @@ import { useEffect, useMemo, useState, type FC } from "react"; import { useParams } from "react-router-dom"; +import { toast } from "react-toastify"; import PageLoading from "../../components/PageLoading"; +import { ErrorType } from "../../helpers/types"; import AddNewColumn from "./components/AddNewColumn"; import Column from "./components/Column"; import HeaderWorkspace from "./components/HeaderWorkspace"; import TaskDetailModal from "./components/task-detail/TaskDetailModal"; import { useGetProject } from "./project/hooks/useProjectData"; +import { useUpdateTaskPhase } from "./task-phase/hooks/useTaskPhaseData"; +import { useChangeTaskPhase } from "./task/hooks/useTaskData"; import { mapTaskItemToTask } from "./task/utils/mapTaskItemToTask"; import type { TaskItemType } from "./task/types/TaskTypes"; import type { TaskPhaseItemType } from "./task-phase/types/TaskPhaseTypes"; @@ -16,6 +20,8 @@ import { reorderTasks } from "./utils/reorderTasks"; const Workspace: FC = () => { const { slug: projectId = "" } = useParams<{ slug: string }>(); const { data: project, isPending } = useGetProject(projectId); + const changeTaskPhase = useChangeTaskPhase(); + const updateTaskPhase = useUpdateTaskPhase(); const [columns, setColumns] = useState([]); const [tasks, setTasks] = useState([]); const [draggedTaskId, setDraggedTaskId] = useState(null); @@ -66,8 +72,48 @@ const Workspace: FC = () => { const handleDrop = (columnId: string, index: number) => { if (!draggedTaskId) return; - setTasks((prev) => reorderTasks(prev, draggedTaskId, columnId, index)); + + const draggedTask = tasks.find((task) => task.id === draggedTaskId); + if (!draggedTask) return; + + const previousTasks = tasks; + const columnChanged = draggedTask.columnId !== columnId; + const nextTasks = reorderTasks(tasks, draggedTaskId, columnId, index); + const movedTask = nextTasks.find((task) => task.id === draggedTaskId) ?? null; + + setTasks(nextTasks); setDraggedTaskId(null); + + if (selectedTask?.id === draggedTaskId && movedTask) { + setSelectedTask(movedTask); + } + + if (!columnChanged) return; + + changeTaskPhase.mutate( + { id: draggedTaskId, params: { taskPhaseId: columnId }, projectId }, + { + onError: (error: ErrorType) => { + setTasks(previousTasks); + if (selectedTask?.id === draggedTaskId) { + setSelectedTask(draggedTask); + } + toast.error(error.response?.data?.error.message[0]); + }, + }, + ); + }; + + const handleTaskPhaseChanged = (taskId: string, taskPhaseId: string) => { + setTasks((prev) => { + const task = prev.find((item) => item.id === taskId); + if (!task || task.columnId === taskPhaseId) return prev; + + const destinationIndex = prev.filter((item) => item.columnId === taskPhaseId).length; + return reorderTasks(prev, taskId, taskPhaseId, destinationIndex); + }); + + setSelectedTask((prev) => (prev?.id === taskId ? { ...prev, columnId: taskPhaseId } : prev)); }; const handleColumnDragStart = (columnId: string) => { @@ -80,20 +126,37 @@ const Workspace: FC = () => { const handleColumnDrop = (overColumnId: string) => { if (!draggedColumnId || draggedColumnId === overColumnId) return; - setColumns((prev) => reorderColumns(prev, draggedColumnId, overColumnId)); + + const previousColumns = columns; + const nextColumns = reorderColumns(columns, draggedColumnId, overColumnId); + const movedColumn = nextColumns.find((column) => column.id === draggedColumnId); + + setColumns(nextColumns); setDraggedColumnId(null); + + if (!movedColumn?.order) return; + + updateTaskPhase.mutate( + { id: draggedColumnId, params: { order: movedColumn.order }, projectId }, + { + onError: (error: ErrorType) => { + setColumns(previousColumns); + toast.error(error.response?.data?.error.message[0]); + }, + }, + ); }; + const nextColumnOrder = columns.length + ? Math.max(...columns.map((column) => column.order ?? 0)) + 1 + : 1; + const handleColumnCreated = (phase: TaskPhaseItemType) => { - setColumns((prev) => [ - ...prev, - { - id: phase.id, - title: phase.name, - color: phase.color, - order: phase.order, - }, - ]); + setColumns((prev) => + [...prev, { id: phase.id, title: phase.name, color: phase.color, order: phase.order }].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0), + ), + ); }; const handleColumnDeleted = (columnId: string) => { @@ -111,8 +174,6 @@ const Workspace: FC = () => { ]); }; - const selectedTaskColumn = selectedTask ? columns.find((column) => column.id === selectedTask.columnId) : null; - if (isPending) { return ; } @@ -144,7 +205,7 @@ const Workspace: FC = () => {
@@ -153,8 +214,10 @@ const Workspace: FC = () => { setSelectedTask(null)} + onTaskPhaseChanged={handleTaskPhaseChanged} />
);