CREATE AND DELETE column
This commit is contained in:
@@ -1000,6 +1000,12 @@
|
|||||||
"select_date": "انتخاب تاریخ",
|
"select_date": "انتخاب تاریخ",
|
||||||
"projects": "پروژهها",
|
"projects": "پروژهها",
|
||||||
"delete_project": "پاک کردن",
|
"delete_project": "پاک کردن",
|
||||||
|
"add_new_column": "اضافه کردن ستون جدید",
|
||||||
|
"add_column": "اضافه کردن",
|
||||||
|
"column_name": "نام ستون",
|
||||||
|
"delete_column": "حذف ستون",
|
||||||
|
"delete_column_confirm": "آیا از حذف این ستون اطمینان دارید؟",
|
||||||
|
"column_deleted": "ستون با موفقیت حذف شد",
|
||||||
"task_detail": {
|
"task_detail": {
|
||||||
"labels": "برچسب ها",
|
"labels": "برچسب ها",
|
||||||
"user_management": "مدیریت کاربران",
|
"user_management": "مدیریت کاربران",
|
||||||
|
|||||||
@@ -1,13 +1,147 @@
|
|||||||
import { AddCircle } from "iconsax-react";
|
import { Add, AddCircle, CloseCircle } from "iconsax-react";
|
||||||
import { type FC } from "react";
|
import { useRef, useState, type FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import ColorsImage from "../../../assets/images/colors.png";
|
||||||
|
import { ErrorType } from "../../../helpers/types";
|
||||||
|
import { clx } from "../../../helpers/utils";
|
||||||
|
import { useCreateTaskPhase } from "../task-phase/hooks/useTaskPhaseData";
|
||||||
|
import type { TaskPhaseItemType } from "../task-phase/types/TaskPhaseTypes";
|
||||||
|
import { LABEL_COLOR_OPTIONS } from "./task-detail/labels/constants";
|
||||||
|
|
||||||
const AddNewColumn: FC = () => {
|
const DEFAULT_COLUMN_COLOR = "#4F46E5";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
projectId: string;
|
||||||
|
nextOrder: number;
|
||||||
|
onColumnCreated: (phase: TaskPhaseItemType) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddNewColumn: FC<Props> = ({ projectId, nextOrder, onColumnCreated }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const createTaskPhase = useCreateTaskPhase();
|
||||||
|
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [selectedColor, setSelectedColor] = useState(DEFAULT_COLUMN_COLOR);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setName("");
|
||||||
|
setSelectedColor(DEFAULT_COLUMN_COLOR);
|
||||||
|
setIsAdding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName || !projectId) return;
|
||||||
|
|
||||||
|
createTaskPhase.mutate(
|
||||||
|
{
|
||||||
|
name: trimmedName,
|
||||||
|
order: nextOrder,
|
||||||
|
color: selectedColor,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const phase = (data?.data ?? data) as TaskPhaseItemType;
|
||||||
|
onColumnCreated(phase);
|
||||||
|
toast.success(t("success"));
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAdding) {
|
||||||
return (
|
return (
|
||||||
<button type="button" className="bg-[#F0F3F7BF] bg-opacity-75 rounded-full px-4 sm:px-6 py-3 sm:py-4 flex items-center gap-2 shrink-0 self-start cursor-pointer w-[272px] sm:w-[288px] xl:w-[310px] justify-center">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
className="bg-[#F0F3F7BF] bg-opacity-75 rounded-full px-4 sm:px-6 py-3 sm:py-4 flex items-center gap-2 shrink-0 self-start cursor-pointer w-[272px] sm:w-[288px] xl:w-[310px] justify-center"
|
||||||
|
>
|
||||||
<AddCircle size={16} color="black" />
|
<AddCircle size={16} color="black" />
|
||||||
<span className="text-xs text-black">اضافه کردن ستون جدید</span>
|
<span className="text-xs text-black">{t("taskmanager.add_new_column")}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#F0F3F7] rounded-xl p-3 sm:p-4 xl:p-6 w-[272px] sm:w-[288px] xl:w-[310px] shrink-0 self-start flex flex-col gap-3 sm:gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("taskmanager.column_name")}
|
||||||
|
className="w-full bg-white rounded-lg px-3 py-2.5 text-sm outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs mb-3">{t("taskmanager.choose_color")}</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => colorInputRef.current?.click()}
|
||||||
|
className={clx(
|
||||||
|
"size-6 rounded-lg cursor-pointer overflow-hidden",
|
||||||
|
!LABEL_COLOR_OPTIONS.includes(
|
||||||
|
selectedColor as (typeof LABEL_COLOR_OPTIONS)[number],
|
||||||
|
) && "ring-1 ring-[#0047FF] rounded-full",
|
||||||
|
)}
|
||||||
|
aria-label={t("taskmanager.choose_color")}
|
||||||
|
>
|
||||||
|
<img src={ColorsImage} alt="" className="size-full object-cover" />
|
||||||
|
</button>
|
||||||
|
{LABEL_COLOR_OPTIONS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedColor(color)}
|
||||||
|
className={clx(
|
||||||
|
"size-6 rounded-lg cursor-pointer transition-transform",
|
||||||
|
color === "#FFFFFF" && "border border-[#D0D0D0]",
|
||||||
|
selectedColor === color && "ring-2 ring-[#0047FF] ring-offset-1",
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
aria-label={color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={colorInputRef}
|
||||||
|
type="color"
|
||||||
|
value={selectedColor}
|
||||||
|
onChange={(e) => setSelectedColor(e.target.value)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!name.trim() || createTaskPhase.isPending}
|
||||||
|
className="flex items-center gap-2 bg-black text-white text-[13px] rounded-full px-4 py-2 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span>{t("taskmanager.add_column")}</span>
|
||||||
|
<Add size={18} color="white" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetForm}
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label={t("cancel")}
|
||||||
|
>
|
||||||
|
<CloseCircle size={22} color="#888888" variant="Bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AddNewColumn;
|
export default AddNewColumn;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Add, AddCircle, CloseCircle, More } from "iconsax-react";
|
import { Add, AddCircle, CloseCircle } from "iconsax-react";
|
||||||
import { useRef, useState, type FC } from "react";
|
import { useRef, useState, type FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import ModalConfrim from "../../../components/ModalConfrim";
|
||||||
|
import { ErrorType } from "../../../helpers/types";
|
||||||
|
import { useDeleteTaskPhase } from "../task-phase/hooks/useTaskPhaseData";
|
||||||
import type { Column as ColumnType, Task as TaskType } from "../types";
|
import type { Column as ColumnType, Task as TaskType } from "../types";
|
||||||
|
import ColumnMenu from "./ColumnMenu";
|
||||||
import Task from "./Task";
|
import Task from "./Task";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -15,6 +21,7 @@ type Props = {
|
|||||||
onColumnDragEnd: () => void;
|
onColumnDragEnd: () => void;
|
||||||
onColumnDrop: (overColumnId: string) => void;
|
onColumnDrop: (overColumnId: string) => void;
|
||||||
onTaskClick?: (task: TaskType) => void;
|
onTaskClick?: (task: TaskType) => void;
|
||||||
|
onColumnDeleted?: (columnId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Column: FC<Props> = ({
|
const Column: FC<Props> = ({
|
||||||
@@ -29,8 +36,13 @@ const Column: FC<Props> = ({
|
|||||||
onColumnDragEnd,
|
onColumnDragEnd,
|
||||||
onColumnDrop,
|
onColumnDrop,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
|
onColumnDeleted,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const deleteTaskPhase = useDeleteTaskPhase();
|
||||||
|
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [isColumnDragOver, setIsColumnDragOver] = useState(false);
|
const [isColumnDragOver, setIsColumnDragOver] = useState(false);
|
||||||
const columnRef = useRef<HTMLDivElement>(null);
|
const columnRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -116,6 +128,19 @@ const Column: FC<Props> = ({
|
|||||||
onColumnDrop(column.id);
|
onColumnDrop(column.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteColumn = () => {
|
||||||
|
deleteTaskPhase.mutate(column.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onColumnDeleted?.(column.id);
|
||||||
|
setIsDeleteConfirmOpen(false);
|
||||||
|
toast.success(t("taskmanager.column_deleted"));
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error.response?.data?.error.message[0]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={columnRef}
|
ref={columnRef}
|
||||||
@@ -139,15 +164,25 @@ const Column: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="flex justify-between items-center shrink-0 gap-2">
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable
|
||||||
onDragStart={handleColumnDragStart}
|
onDragStart={handleColumnDragStart}
|
||||||
onDragEnd={handleColumnDragEnd}
|
onDragEnd={handleColumnDragEnd}
|
||||||
className="flex justify-between items-center shrink-0 cursor-grab active:cursor-grabbing"
|
className="flex-1 min-w-0 font-bold text-sm truncate cursor-grab active:cursor-grabbing"
|
||||||
>
|
>
|
||||||
<div className="font-bold text-sm truncate">{column.title}</div>
|
{column.title}
|
||||||
<More size={20} color="black" className="shrink-0 pointer-events-none" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<ColumnMenu onDelete={() => setIsDeleteConfirmOpen(true)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalConfrim
|
||||||
|
isOpen={isDeleteConfirmOpen}
|
||||||
|
close={() => setIsDeleteConfirmOpen(false)}
|
||||||
|
onConfrim={handleDeleteColumn}
|
||||||
|
isLoading={deleteTaskPhase.isPending}
|
||||||
|
label={t("taskmanager.delete_column_confirm")}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-3 sm:mt-4 xl:mt-5 flex-1 min-h-0 overflow-y-auto overscroll-y-contain flex flex-col gap-3 sm:gap-4">
|
<div className="mt-3 sm:mt-4 xl:mt-5 flex-1 min-h-0 overflow-y-auto overscroll-y-contain flex flex-col gap-3 sm:gap-4">
|
||||||
{tasks.map((task, index) => (
|
{tasks.map((task, index) => (
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||||
|
import { More } from "iconsax-react";
|
||||||
|
import { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ColumnMenu: FC<Props> = ({ onDelete }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover className="relative">
|
||||||
|
<PopoverButton
|
||||||
|
className="flex items-center justify-center size-8 rounded-lg hover:bg-black/5 transition-colors outline-none shrink-0"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<More size={20} color="black" />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverPanel
|
||||||
|
anchor="bottom start"
|
||||||
|
className="z-20 mt-1 min-w-[120px] rounded-xl bg-white shadow-md border border-border py-1 text-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="w-full px-4 py-2.5 text-right text-red-500 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
{t("taskmanager.delete_column")}
|
||||||
|
</button>
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColumnMenu;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/TaskPhaseService";
|
||||||
|
import { CreateTaskPhaseType } from "../types/TaskPhaseTypes";
|
||||||
|
|
||||||
|
export const useCreateTaskPhase = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CreateTaskPhaseType) => api.createTaskPhase(variables),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["task-phases", variables.projectId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteTaskPhase = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteTaskPhase(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["task-phases"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import axios from "../../../../config/axios";
|
||||||
|
import { CreateTaskPhaseType } from "../types/TaskPhaseTypes";
|
||||||
|
|
||||||
|
export const createTaskPhase = async (params: CreateTaskPhaseType) => {
|
||||||
|
const { data } = await axios.post(`/task-manager/task-phases`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTaskPhase = async (id: string) => {
|
||||||
|
const { data } = await axios.delete(`/task-manager/task-phases/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type CreateTaskPhaseType = {
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
color: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskPhaseItemType = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
color: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export type Column = {
|
export type Column = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
color?: string;
|
||||||
|
order?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Task = {
|
export type Task = {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useMemo, useState, type FC } from "react";
|
import { useMemo, useState, type FC } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
import AddNewColumn from "./components/AddNewColumn";
|
import AddNewColumn from "./components/AddNewColumn";
|
||||||
import Column from "./components/Column";
|
import Column from "./components/Column";
|
||||||
import HeaderWorkspace from "./components/HeaderWorkspace";
|
import HeaderWorkspace from "./components/HeaderWorkspace";
|
||||||
import TaskDetailModal from "./components/task-detail/TaskDetailModal";
|
import TaskDetailModal from "./components/task-detail/TaskDetailModal";
|
||||||
import tasksData from "./data/tasks.json";
|
import tasksData from "./data/tasks.json";
|
||||||
|
import type { TaskPhaseItemType } from "./task-phase/types/TaskPhaseTypes";
|
||||||
import type { Task, WorkspaceData } from "./types";
|
import type { Task, WorkspaceData } from "./types";
|
||||||
import { reorderColumns } from "./utils/reorderColumns";
|
import { reorderColumns } from "./utils/reorderColumns";
|
||||||
import { reorderTasks } from "./utils/reorderTasks";
|
import { reorderTasks } from "./utils/reorderTasks";
|
||||||
|
|
||||||
const Workspace: FC = () => {
|
const Workspace: FC = () => {
|
||||||
|
const { slug: projectId = "" } = useParams<{ slug: string }>();
|
||||||
const [columns, setColumns] = useState(
|
const [columns, setColumns] = useState(
|
||||||
(tasksData as WorkspaceData).columns,
|
(tasksData as WorkspaceData).columns,
|
||||||
);
|
);
|
||||||
@@ -54,6 +57,26 @@ const Workspace: FC = () => {
|
|||||||
setDraggedColumnId(null);
|
setDraggedColumnId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleColumnCreated = (phase: TaskPhaseItemType) => {
|
||||||
|
setColumns((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: phase.id,
|
||||||
|
title: phase.name,
|
||||||
|
color: phase.color,
|
||||||
|
order: phase.order,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColumnDeleted = (columnId: string) => {
|
||||||
|
setColumns((prev) => prev.filter((column) => column.id !== columnId));
|
||||||
|
setTasks((prev) => prev.filter((task) => task.columnId !== columnId));
|
||||||
|
if (selectedTask?.columnId === columnId) {
|
||||||
|
setSelectedTask(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const selectedTaskColumn = selectedTask
|
const selectedTaskColumn = selectedTask
|
||||||
? columns.find((column) => column.id === selectedTask.columnId)
|
? columns.find((column) => column.id === selectedTask.columnId)
|
||||||
: null;
|
: null;
|
||||||
@@ -77,10 +100,15 @@ const Workspace: FC = () => {
|
|||||||
onColumnDragEnd={handleColumnDragEnd}
|
onColumnDragEnd={handleColumnDragEnd}
|
||||||
onColumnDrop={handleColumnDrop}
|
onColumnDrop={handleColumnDrop}
|
||||||
onTaskClick={setSelectedTask}
|
onTaskClick={setSelectedTask}
|
||||||
|
onColumnDeleted={handleColumnDeleted}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<AddNewColumn />
|
<AddNewColumn
|
||||||
|
projectId={projectId}
|
||||||
|
nextOrder={columns.length}
|
||||||
|
onColumnCreated={handleColumnCreated}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user