update task phase
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
hamid zarghami
2026-07-12 12:29:52 +03:30
parent fd545231d3
commit 95177b529e
10 changed files with 205 additions and 33 deletions
@@ -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<Props> = ({ statusLabel, onClose, onDelete, isDeleting }) => {
const TaskDetailHeader: FC<Props> = ({
columns,
selectedPhaseId,
onPhaseChange,
isChangingPhase,
onClose,
onDelete,
isDeleting,
}) => {
const selectedColumn = columns.find((column) => column.id === selectedPhaseId);
return (
<div className="flex items-center justify-between border-b border-border pb-4">
<button type="button" className="flex items-center gap-1.5 bg-white/60 rounded-full px-4 py-2 text-xs font-medium cursor-pointer">
<span>{statusLabel}</span>
<Popover className="relative">
<PopoverButton
disabled={isChangingPhase}
className="flex items-center gap-1.5 bg-white/60 rounded-full px-4 py-2 text-xs font-medium cursor-pointer disabled:opacity-50 outline-none"
>
<span>{selectedColumn?.title ?? ""}</span>
<ArrowDown2 size={14} color="#292D32" />
</PopoverButton>
<PopoverPanel
anchor="bottom start"
className="z-20 mt-1 min-w-[160px] rounded-xl bg-white shadow-md border border-border py-1 text-sm"
>
{columns.map((column) => (
<button
key={column.id}
type="button"
onClick={() => onPhaseChange(column.id)}
className={clx(
"w-full px-4 py-2.5 text-right transition-colors hover:bg-black/5",
column.id === selectedPhaseId && "font-bold bg-black/5",
)}
>
{column.title}
</button>
))}
</PopoverPanel>
</Popover>
<div className="flex items-center gap-2">
{onDelete ? (
@@ -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<Props> = ({ open, task, statusLabel, onClose }) => {
const TaskDetailModal: FC<Props> = ({ open, task, columns, projectId, onClose, onTaskPhaseChanged }) => {
const { t } = useTranslation("global");
const [activeTab, setActiveTab] = useState<TaskDetailTab | null>(null);
const [title, setTitle] = useState("");
@@ -31,6 +33,7 @@ const TaskDetailModal: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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 (
<DefaulModal open={open} close={onClose} width={680}>
@@ -120,7 +140,10 @@ const TaskDetailModal: FC<Props> = ({ open, task, statusLabel, onClose }) => {
) : (
<>
<TaskDetailHeader
statusLabel={statusLabel}
columns={columns}
selectedPhaseId={selectedPhaseId}
onPhaseChange={handlePhaseChange}
isChangingPhase={changeTaskPhase.isPending}
onClose={onClose}
onDelete={handleDelete}
isDeleting={deleteTask.isPending}
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/TaskPhaseService";
import { CreateTaskPhaseType } from "../types/TaskPhaseTypes";
import { CreateTaskPhaseType, UpdateTaskPhaseType } from "../types/TaskPhaseTypes";
export const useCreateTaskPhase = () => {
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();
@@ -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;
@@ -5,6 +5,10 @@ export type CreateTaskPhaseType = {
projectId: string;
};
export type UpdateTaskPhaseType = {
order: number;
};
export type TaskPhaseItemType = {
id: string;
name: string;
@@ -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();
@@ -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;
@@ -107,6 +107,10 @@ export type TaskDetailType = {
attachments?: TaskAttachmentType[];
};
export type ChangeTaskPhaseType = {
taskPhaseId: string;
};
export type UpdateTaskType = Partial<
Pick<TaskDetailType, "title" | "description" | "taskPhaseId" | "order" | "startDate" | "endDate">
> & {
@@ -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 }));
};
+78 -15
View File
@@ -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<ColumnType[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(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 <PageLoading />;
}
@@ -144,7 +205,7 @@ const Workspace: FC = () => {
<AddNewColumn
projectId={projectId}
nextOrder={columns.length}
nextOrder={nextColumnOrder}
onColumnCreated={handleColumnCreated}
/>
</div>
@@ -153,8 +214,10 @@ const Workspace: FC = () => {
<TaskDetailModal
open={selectedTask !== null}
task={selectedTask}
statusLabel={selectedTaskColumn?.title ?? ""}
columns={columns}
projectId={projectId}
onClose={() => setSelectedTask(null)}
onTaskPhaseChanged={handleTaskPhaseChanged}
/>
</div>
);