This commit is contained in:
@@ -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 }));
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user