306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import { DragDropContext, type DragStart, type DropResult } from "@hello-pangea/dnd";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useState, type FC } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { toast } from '../../components/Toast';
|
|
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, useGetProjectDetail } from "./project/hooks/useProjectData";
|
|
import ProjectSettingsPanel from "./project/components/ProjectSettingsPanel";
|
|
import type { ProjectDetailType } from "./project/types/ProjectTypes";
|
|
import { getProjectBackgroundStyle } from "./project/utils/getProjectBackgroundStyle";
|
|
import { useUpdateTaskPhase } from "./task-phase/hooks/useTaskPhaseData";
|
|
import type { TaskPhaseItemType } from "./task-phase/types/TaskPhaseTypes";
|
|
import { useChangeTaskPhase, useChangeTaskPriority } from "./task/hooks/useTaskData";
|
|
import type { TaskItemType } from "./task/types/TaskTypes";
|
|
import { mapTaskItemToTask } from "./task/utils/mapTaskItemToTask";
|
|
import type { Column as ColumnType, Task } from "./types";
|
|
import { getPriorityDestinationTaskId } from "./utils/getPriorityDestinationTaskId";
|
|
import { reorderColumns } from "./utils/reorderColumns";
|
|
import { reorderTaskItems } from "./utils/reorderTaskItems";
|
|
import { getTaskPhaseItems, insertTaskPhaseItem, updateTaskPhaseItems, type TasksByTaskPhaseInfiniteData } from "./utils/taskPhaseQueryCache";
|
|
|
|
const Workspace: FC = () => {
|
|
const queryClient = useQueryClient();
|
|
const { slug: projectId = "" } = useParams<{ slug: string }>();
|
|
const { data: project, isPending } = useGetProject(projectId);
|
|
const { data: projectDetailData } = useGetProjectDetail(projectId);
|
|
const projectDetail: ProjectDetailType | undefined =
|
|
projectDetailData?.data?.project ?? projectDetailData?.data;
|
|
const { mutate: changeTaskPhase } = useChangeTaskPhase();
|
|
const { mutate: changeTaskPriority } = useChangeTaskPriority();
|
|
const { mutate: updateTaskPhase } = useUpdateTaskPhase();
|
|
const [columns, setColumns] = useState<ColumnType[]>([]);
|
|
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
|
|
const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null);
|
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!project?.data?.taskPhases) return;
|
|
|
|
setColumns(
|
|
[...project.data.taskPhases]
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((phase) => ({
|
|
id: phase.id,
|
|
title: phase.name,
|
|
color: phase.color,
|
|
order: phase.order,
|
|
})),
|
|
);
|
|
}, [project?.data?.taskPhases]);
|
|
|
|
const handleTaskDragStart = (start: DragStart) => {
|
|
if (start.type !== "TASK") return;
|
|
|
|
const sourceColumnId = start.source.droppableId;
|
|
const previousData = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", sourceColumnId]);
|
|
const items = getTaskPhaseItems(previousData);
|
|
const item = items.find((task) => task.id === start.draggableId);
|
|
if (!item) return;
|
|
|
|
setDraggedTask(mapTaskItemToTask(item as TaskItemType, sourceColumnId, start.source.index));
|
|
};
|
|
|
|
const handleDrop = (columnId: string, index: number, activeTask: Task) => {
|
|
const sourceColumnId = activeTask.columnId;
|
|
const columnChanged = sourceColumnId !== columnId;
|
|
|
|
if (!columnChanged) {
|
|
const previousData = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId]);
|
|
const currentItems = getTaskPhaseItems(previousData);
|
|
const destTaskId = getPriorityDestinationTaskId(currentItems, activeTask.id, index);
|
|
|
|
if (!destTaskId) return;
|
|
|
|
queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId], (current) =>
|
|
updateTaskPhaseItems(current, (items) => reorderTaskItems(items, activeTask.id, index)),
|
|
);
|
|
|
|
changeTaskPriority(
|
|
{ srcId: activeTask.id, destId: destTaskId, taskPhaseId: columnId, projectId },
|
|
{
|
|
onError: (error: ErrorType) => {
|
|
if (previousData) {
|
|
queryClient.setQueryData(["tasks-by-task-phase", columnId], previousData);
|
|
}
|
|
toast(error.response?.data?.error.message[0], 'error');
|
|
},
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
const previousSource = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", sourceColumnId]);
|
|
const previousDest = queryClient.getQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId]);
|
|
|
|
queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", sourceColumnId], (current) =>
|
|
updateTaskPhaseItems(current, (items) => items.filter((task) => task.id !== activeTask.id)),
|
|
);
|
|
|
|
queryClient.setQueryData<TasksByTaskPhaseInfiniteData>(["tasks-by-task-phase", columnId], (current) =>
|
|
insertTaskPhaseItem(
|
|
current,
|
|
{
|
|
id: activeTask.id,
|
|
title: activeTask.title,
|
|
taskPhaseId: columnId,
|
|
order: index,
|
|
priority: index,
|
|
tag: activeTask.tag,
|
|
dateRange: activeTask.dateRange,
|
|
color: activeTask.color,
|
|
attachments: activeTask.attachments,
|
|
checklistDone: activeTask.checklistDone,
|
|
checklistTotal: activeTask.checklistTotal,
|
|
},
|
|
index,
|
|
),
|
|
);
|
|
|
|
const destTaskId = getPriorityDestinationTaskId(getTaskPhaseItems(previousDest), activeTask.id, index, false);
|
|
|
|
if (selectedTask?.id === activeTask.id) {
|
|
setSelectedTask({ ...activeTask, columnId });
|
|
}
|
|
|
|
changeTaskPhase(
|
|
{ id: activeTask.id, params: { taskPhaseId: columnId }, projectId },
|
|
{
|
|
onSuccess: () => {
|
|
if (destTaskId) {
|
|
changeTaskPriority(
|
|
{ srcId: activeTask.id, destId: destTaskId, taskPhaseId: columnId, projectId },
|
|
{
|
|
onError: (error: ErrorType) => {
|
|
queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", sourceColumnId] });
|
|
queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", columnId] });
|
|
toast(error.response?.data?.error.message[0], 'error');
|
|
},
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", sourceColumnId] });
|
|
queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", columnId] });
|
|
},
|
|
onError: (error: ErrorType) => {
|
|
if (previousSource) {
|
|
queryClient.setQueryData(["tasks-by-task-phase", sourceColumnId], previousSource);
|
|
}
|
|
if (previousDest) {
|
|
queryClient.setQueryData(["tasks-by-task-phase", columnId], previousDest);
|
|
}
|
|
if (selectedTask?.id === activeTask.id) {
|
|
setSelectedTask(activeTask);
|
|
}
|
|
toast(error.response?.data?.error.message[0], 'error');
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const handleTaskDragEnd = (result: DropResult) => {
|
|
const activeTask = draggedTask;
|
|
setDraggedTask(null);
|
|
|
|
if (!activeTask || !result.destination || result.type !== "TASK") return;
|
|
|
|
const { source, destination } = result;
|
|
if (source.droppableId === destination.droppableId && source.index === destination.index) return;
|
|
|
|
handleDrop(destination.droppableId, destination.index, activeTask);
|
|
};
|
|
|
|
const handleTaskPhaseChanged = (taskId: string, taskPhaseId: string) => {
|
|
const sourceColumnId = selectedTask?.columnId;
|
|
|
|
if (sourceColumnId) {
|
|
queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", sourceColumnId] });
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ["tasks-by-task-phase", taskPhaseId] });
|
|
|
|
setSelectedTask((prev) => (prev?.id === taskId ? { ...prev, columnId: taskPhaseId } : prev));
|
|
};
|
|
|
|
const handleColumnDragStart = (columnId: string) => {
|
|
setDraggedColumnId(columnId);
|
|
};
|
|
|
|
const handleColumnDragEnd = () => {
|
|
setDraggedColumnId(null);
|
|
};
|
|
|
|
const handleColumnDrop = (overColumnId: string) => {
|
|
if (!draggedColumnId || draggedColumnId === overColumnId) return;
|
|
|
|
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(
|
|
{ id: draggedColumnId, params: { order: movedColumn.order }, projectId },
|
|
{
|
|
onError: (error: ErrorType) => {
|
|
setColumns(previousColumns);
|
|
toast(error.response?.data?.error.message[0], 'error');
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
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 }].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)));
|
|
};
|
|
|
|
const handleColumnDeleted = (columnId: string) => {
|
|
setColumns((prev) => prev.filter((column) => column.id !== columnId));
|
|
queryClient.removeQueries({ queryKey: ["tasks-by-task-phase", columnId] });
|
|
if (selectedTask?.columnId === columnId) {
|
|
setSelectedTask(null);
|
|
}
|
|
};
|
|
|
|
const handleColumnTitleUpdated = (columnId: string, title: string) => {
|
|
setColumns((prev) => prev.map((column) => (column.id === columnId ? { ...column, title } : column)));
|
|
};
|
|
|
|
if (isPending) {
|
|
return <PageLoading />;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="h-full min-h-0 rounded-none xl:rounded-[32px] overflow-hidden flex flex-col"
|
|
style={getProjectBackgroundStyle({
|
|
backgroundColor: projectDetail?.backgroundColor,
|
|
backgroundPicture: projectDetail?.backgroundPicture,
|
|
})}
|
|
>
|
|
<HeaderWorkspace
|
|
projectName={project?.data?.name ?? ""}
|
|
onSettingsClick={() => setIsSettingsOpen(true)}
|
|
/>
|
|
<div className="flex-1 min-h-0 flex flex-col px-3 sm:px-4 xl:px-8 py-4 xl:py-6">
|
|
<DragDropContext onDragStart={handleTaskDragStart} onDragEnd={handleTaskDragEnd}>
|
|
<div className="flex-1 min-h-0 overflow-x-auto overflow-y-hidden overscroll-x-contain">
|
|
<div className="flex gap-3 sm:gap-4 xl:gap-7 h-full min-h-0 items-stretch w-max min-w-full">
|
|
{columns.map((column) => (
|
|
<Column
|
|
key={column.id}
|
|
column={column}
|
|
projectId={projectId}
|
|
draggedColumnId={draggedColumnId}
|
|
onColumnDragStart={handleColumnDragStart}
|
|
onColumnDragEnd={handleColumnDragEnd}
|
|
onColumnDrop={handleColumnDrop}
|
|
onTaskClick={setSelectedTask}
|
|
onColumnDeleted={handleColumnDeleted}
|
|
onColumnTitleUpdated={handleColumnTitleUpdated}
|
|
/>
|
|
))}
|
|
|
|
<AddNewColumn
|
|
projectId={projectId}
|
|
nextOrder={nextColumnOrder}
|
|
onColumnCreated={handleColumnCreated}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</DragDropContext>
|
|
</div>
|
|
|
|
<TaskDetailModal
|
|
open={selectedTask !== null}
|
|
task={selectedTask}
|
|
columns={columns}
|
|
projectId={projectId}
|
|
onClose={() => setSelectedTask(null)}
|
|
onTaskPhaseChanged={handleTaskPhaseChanged}
|
|
/>
|
|
|
|
<ProjectSettingsPanel
|
|
open={isSettingsOpen}
|
|
onClose={() => setIsSettingsOpen(false)}
|
|
projectId={projectId}
|
|
project={projectDetail}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Workspace;
|